Embedded Gralkor memory for Elixir/OTP. Runs Graphiti in-process via Pythonx, connects to FalkorDB either as an in-process falkordblite child or over the network to a managed FalkorDB, and calls LLMs from Elixir via req_llm. No HTTP, no Python server child — pick a FalkorDB backend (below) and Gralkor.Client works.

Prerequisites

  • An LLM provider API key — GOOGLE_API_KEY (default) or whichever provider you've configured for req_llm.
  • A FalkorDB to talk to. Pick one:
    • Embedded — set GRALKOR_DATA_DIR to a writable directory; falkordblite spawns a local redis-server grandchild under it. Convenient for development.
    • Remote — set config :gralkor_ex, falkordb: [host:, port:, username:, password:] in config/runtime.exs. :gralkor_ex connects directly via the network and never imports redislite. Suited to managed FalkorDB deployments. Remote wins when both are set.

The Python interpreter and all Python deps (graphiti-core + falkordblite + provider extras) are materialised into a uv-managed venv on first boot via Pythonx — no separate Python install, no uv run, no Docker.

Install

def deps do
  [
    {:gralkor_ex, "~> 2.2"}
  ]
end

Using Gralkor from a Jido agent? Install :jido_gralkor instead — it pulls :gralkor_ex transitively and ships the Jido-shaped glue.

API surface

  • Gralkor.Client — the port. Behaviour with recall/3, capture/3, end_session/1, memory_add/3, build_indices/0, build_communities/1. Includes sanitize_group_id/1 and impl/0 (resolves the configured adapter from Application.get_env(:gralkor_ex, :client); defaults to Gralkor.Client.Native). No health_check/0 — the embedded runtime is ready by the time Application.start/2 returns; runtime failures surface from the next call.
  • Gralkor.Client.Native — production adapter. Wires Gralkor.Recall + Gralkor.GraphitiPool + Gralkor.CaptureBuffer + req_llm. No HTTP.
  • Gralkor.Client.InMemory — test-only twin satisfying the same port contract. Records calls, returns canned responses. Swap via config :gralkor_ex, client: Gralkor.Client.InMemory in config/test.exs. Call reset/0 in setup.
  • Gralkor.Python — owns the PythonX runtime. In embedded mode, SIGKILLs orphan redislite/bin/redis-server processes; in remote mode, no reaping (the redis-server children belong to FalkorDB, not us). Smoke-imports graphiti_core. First child of the supervision tree.
  • Gralkor.GraphitiPool — per-group Graphiti instance cache (ETS-backed for concurrent reads, GenServer for lifecycle). Owns the shared AsyncFalkorDB (constructed from the resolved falkordb_spec — embedded redislite or remote falkordb.asyncio.FalkorDB). The Python objects live here.
  • Gralkor.CaptureBuffer — in-flight conversation buffer keyed by session_id. Holds turns until an explicit flush. Retry semantics: server-internal failures back off 1s/2s/4s; 4xx and upstream-LLM errors drop without retry.
  • Gralkor.Recall, Gralkor.Distill, Gralkor.Interpret, Gralkor.Format — pure pipelines; LLM calls go through req_llm.
  • Gralkor.Config — boot-time config: falkordb_spec/0 (returns {:remote, kw} | {:embedded, dir} | nil; remote wins; validates host/port), llm_model/0, embedder_model/0.

Architecture (one paragraph)

The BEAM hosts CPython via Pythonx. Graphiti's async APIs are invoked from Elixir as Pythonx.eval blocks wrapping asyncio.run(...). The GIL is released during graphiti's awaited I/O, so concurrent Elixir callers parallelise (8 concurrent calls finish in ~1× single-call latency, not 8×). LLM calls outside of graphiti's internals (Distill's behaviour summarisation, Interpret's relevance filtering) go through req_llm directly from Elixir — graphiti's bundled clients only handle graphiti's own internal LLM/embedder calls during add_episode and search.

Usage

:gralkor_ex starts its own supervision tree at app boot when a FalkorDB backend is configured (either GRALKOR_DATA_DIR for embedded or :gralkor_ex, :falkordb for remote). No need to add Gralkor.GraphitiPool or Gralkor.CaptureBuffer yourself.

# Embedded
export GRALKOR_DATA_DIR=/tmp/gralkor-dev
export GOOGLE_API_KEY=...
iex -S mix
# Remote — config/runtime.exs
config :gralkor_ex,
  falkordb: [
    host: System.fetch_env!("FALKORDB_HOST"),
    port: String.to_integer(System.fetch_env!("FALKORDB_PORT")),
    username: System.get_env("FALKORDB_USERNAME"),
    password: System.get_env("FALKORDB_PASSWORD")
  ]
Gralkor.Client.impl().memory_add("group", "Eli prefers concise explanations", "manual")
{:ok, block} = Gralkor.Client.impl().recall("group", "session-1", "preferences?")
IO.puts(block)  # <gralkor-memory trust="untrusted">…</gralkor-memory>

Configuration

Pick one FalkorDB backend:

  • EmbeddedGRALKOR_DATA_DIR (env var, writable directory).
  • Remoteconfig :gralkor_ex, falkordb: [host:, port:, username:, password:] in config/runtime.exs. Misconfigured (non-keyword, missing host/port, blank host, non-positive port) raises ArgumentError at app start.

Optional:

  • GRALKOR_LLM_MODELreq_llm model string (e.g. "google:gemini-2.0-flash"). Default applied if unset.
  • GRALKOR_EMBEDDER_MODEL — same shape; for graphiti's internal embedder.
  • Provider API keys: GOOGLE_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY (whichever your providers need).

Lifecycle

The supervision tree starts in order:

  1. Gralkor.Python — synchronous boot. In embedded mode, SIGKILLs any orphan redislite/bin/redis-server left over from a prior BEAM crash (the path is unique to falkordblite). In remote mode, this reap is skipped — those processes belong to FalkorDB, not us. Smoke-imports graphiti_core so any venv / import failure surfaces at boot.
  2. Gralkor.GraphitiPool — synchronous init. Constructs the shared AsyncFalkorDB from the resolved falkordb_spec: embedded mode uses redislite.async_falkordb_client.AsyncFalkorDB(<data_dir>/gralkor.db) (which spawns a redis-server grandchild owned by the BEAM); remote mode uses falkordb.asyncio.FalkorDB(host:, port:, username:, password:). Registers an ETS table for the per-group Graphiti instance cache, runs warmup.
  3. Gralkor.CaptureBuffer — starts with a flush callback that distils via req_llm and ingests via GraphitiPool.add_episode.

Application.start/2 returns only after all three have initialised — there is no separate Gralkor.Connection readiness gate.

Test mode

config :gralkor_ex, test: true

Surfaces the raw data crossing the recall and capture boundaries — query, returned memory block, captured messages, and the distilled episode body — at :info so it appears in normal logs without flipping the global Logger level. The [gralkor] [test] prefix on each line keeps it greppable. Useful when debugging what an agent is actually seeing or storing; off by default.

License

MIT.