gralkor_ex

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

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

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:

Optional:

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.