Class: Pikuri::Memory::Mem0Server

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/memory/mem0_server.rb

Overview

Supervisor for a self-managed mem0 + Qdrant sidecar. This class owns the stack (clone + patch the mem0 source, +docker compose up --build+, heartbeat-poll, tear down); Mem0Client owns the HTTP client, and #client returns one pre-pointed at the running server. A host already running mem0 elsewhere skips this class and wires Mem0Client.new(endpoint:) directly (the +Server::Chroma+-shaped let-pikuri-manage-it vs bring-your-own choice).

Mem0Server.ensure_running(router_url: 'http://localhost:8080/v1',
                        llm_model: 'qwen2.5-7b', embedder_model: 'nomic')
        .client   # => a Mem0Client pointed at 127.0.0.1:8888

Why a compose stack, not a single docker run

mem0's REST server has no usable published image (the Docker Hub one is pre-v3), so it is built from source, and it needs Qdrant as a second container. Two interdependent services with a build step is what docker compose models — driven here (through Subprocess.spawn) over the compose file in the gem's docker/ directory.

No Postgres; Qdrant, patched in at build

Upstream's DEFAULT_CONFIG hardcodes pgvector, whose mem0 provider has a top-k inversion bug (cosine distance ranked as a similarity) and connects to Postgres eagerly at boot. #prepare_checkout! applies docker/qdrant-default-config.patch to swap the default to Qdrant (env-driven host/port/dims) — correct ranking and a Postgres-free stack. See DESIGN.md §"Root cause: the pgvector top-k inversion".

Local LLM + embedder via the router

mem0's provider validation accepts only +openai+/+anthropic+/+gemini+ (LLM) and +openai+/+gemini+ (embedder), so the local path keeps provider: "openai" and points OPENAI_BASE_URL at the llama.cpp router. The extraction model must be non-reasoning (e.g. Qwen2.5-7B-Instruct) — a thinking model burns its budget on CoT and returns empty/truncated JSON (DESIGN.md §"Extraction model decision"). The vector store is not bundle-gated, so Qdrant is fine.

The router relay (container → host loopback)

The LLM + embedder calls originate inside the container, but the router conventionally binds the host's 127.0.0.1:8080 — which rootless docker refuses to route containers to (+--disable-host-loopback+; re-enabling it daemon-wide would hand every container, incl. untrusted MCP ones, a path to every loopback-bound host service: CUPS, trust-auth Postgres, an unauthenticated Redis…). Instead the supervisor builds a scoped hole:

  1. Host side: socat UNIX-LISTEN:<sock_dir>/router.sock,… TCP:<router>, spawned as a daemon child (never +#wait+ed; stopped with Subprocess#terminate, swept by the exit reaper as a backstop).
  2. The compose stack's router-proxy sidecar (pinned SOCAT_IMAGE) bind-mounts the socket directory and relays it onto the stack-internal network as http://router-proxy:8080.
  3. mem0's OPENAI_BASE_URL points at that sidecar.

The socket file is the capability: only a container that mounts it reaches the router, the host loopback stays sealed for everything else, and the wiring is identical on rootful and rootless daemons. The relay speaks plain TCP, so router_url must be http://; an https router needs container_router_url: (bypasses the relay, caller's routing problem).

Pinned + patched checkout

Built from MEM0_REF (a pinned commit), cloned once and reused. The patch is applied with git apply and is fail-loud: if it neither applies cleanly nor is already applied, #prepare_checkout! raises rather than build a wrong image.

Both published ports bind 127.0.0.1 only — the memory never listens on a routable interface. Boot errors (missing docker/git, build failure, healthcheck timeout) raise RuntimeError with the offending output; teardown failures are logged, not raised.

Constant Summary collapse

LOGGER =
Pikuri.logger_for('Memory::Mem0Server')
MEM0_REPO_URL =

Returns mem0 git remote the server is built from.

Returns:

  • (String)

    mem0 git remote the server is built from.

'https://github.com/mem0ai/mem0.git'
MEM0_REF =

Returns pinned mem0 commit the image is built from (library 2.0.4, v3 token-efficient algorithm). Bumping this is how the mem0 version is upgraded — and the shipped patch must be regenerated against the new ref if DEFAULT_CONFIG moved.

Returns:

  • (String)

    pinned mem0 commit the image is built from (library 2.0.4, v3 token-efficient algorithm). Bumping this is how the mem0 version is upgraded — and the shipped patch must be regenerated against the new ref if DEFAULT_CONFIG moved.

'a3154d59e52386d4e1189c1f5f44819868f76514'
COMPOSE_PROJECT =

Returns compose project name. The pikuri-internal- prefix is the namespace pikuri squats for self-managed infra.

Returns:

  • (String)

    compose project name. The pikuri-internal- prefix is the namespace pikuri squats for self-managed infra.

'pikuri-internal-mem0'
COMPOSE_FILE =

Returns absolute path to the shipped compose file.

Returns:

  • (String)

    absolute path to the shipped compose file.

File.expand_path('../../../docker/docker-compose.yml', __dir__)
PATCH_FILE =

Returns absolute path to the shipped DEFAULT_CONFIG pgvector→qdrant patch.

Returns:

  • (String)

    absolute path to the shipped DEFAULT_CONFIG pgvector→qdrant patch.

File.expand_path('../../../docker/qdrant-default-config.patch', __dir__)
DEFAULT_PORT =

Returns default host port mem0's REST API binds (127.0.0.1).

Returns:

  • (Integer)

    default host port mem0's REST API binds (127.0.0.1).

8888
DEFAULT_QDRANT_PORT =

Returns default host port Qdrant binds (127.0.0.1), published for inspection only.

Returns:

  • (Integer)

    default host port Qdrant binds (127.0.0.1), published for inspection only.

6333
DEFAULT_EMBEDDING_DIMS =

Returns default embedding dimension. 768 matches nomic-embed-text-v1.5; must equal the embedder's output dim or Qdrant rejects upserts.

Returns:

  • (Integer)

    default embedding dimension. 768 matches nomic-embed-text-v1.5; must equal the embedder's output dim or Qdrant rejects upserts.

768
DEFAULT_COLLECTION =

Returns default Qdrant collection name.

Returns:

  • (String)

    default Qdrant collection name.

'pikuri_memory'
DEFAULT_QDRANT_IMAGE =

Returns pinned Qdrant image. Kept in lockstep with +Pikuri::VectorDb::Server::Qdrant::IMAGE+ (same tag → one image on disk for a host running both stacks). A convention, not a shared constant (the gems don't depend on each other); bump both together.

Returns:

  • (String)

    pinned Qdrant image. Kept in lockstep with +Pikuri::VectorDb::Server::Qdrant::IMAGE+ (same tag → one image on disk for a host running both stacks). A convention, not a shared constant (the gems don't depend on each other); bump both together.

'qdrant/qdrant:v1.12.4'
DEFAULT_HEALTHCHECK_TIMEOUT =

Returns seconds to wait for the REST API to answer after compose up returns. Covers container-start readiness only — the first-run image build (minutes) is inside the blocking --build call, not this poll.

Returns:

  • (Integer)

    seconds to wait for the REST API to answer after compose up returns. Covers container-start readiness only — the first-run image build (minutes) is inside the blocking --build call, not this poll.

60
SOCAT_IMAGE =

Returns pinned socat image for the router-proxy sidecar (class header's "router relay"). Bumped manually.

Returns:

  • (String)

    pinned socat image for the router-proxy sidecar (class header's "router relay"). Bumped manually.

'alpine/socat:1.8.0.3'
RELAY_SOCKET =

Returns socket filename inside #sock_dir; the sidecar sees it as /sock/router.sock.

Returns:

  • (String)

    socket filename inside #sock_dir; the sidecar sees it as /sock/router.sock.

'router.sock'
RELAY_BIND_TIMEOUT =

Returns seconds to wait for the host-side socat to bind the relay socket. Binding is immediate in practice; the timeout fails loud when socat dies on startup (bad address, port typo) rather than surfacing later as silent extraction failures.

Returns:

  • (Integer)

    seconds to wait for the host-side socat to bind the relay socket. Binding is immediate in practice; the timeout fails loud when socat dies on startup (bad address, port typo) rather than surfacing later as silent extraction failures.

5

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(router_url:, llm_model:, embedder_model:, container_router_url: nil, port: DEFAULT_PORT, qdrant_port: DEFAULT_QDRANT_PORT, embedding_dims: DEFAULT_EMBEDDING_DIMS, collection: DEFAULT_COLLECTION, qdrant_image: DEFAULT_QDRANT_IMAGE, cache_dir: nil, healthcheck_timeout: DEFAULT_HEALTHCHECK_TIMEOUT, connection: nil) ⇒ Mem0Server

Parameters:

  • router_url (String)

    llama.cpp OpenAI-compatible base URL as seen from the host (e.g. "http://localhost:8080/v1") the mem0 server routes its LLM + embedder calls to. Must be http:// — the relay (class header) carries it into the container, so a loopback-bound router is fine.

  • llm_model (String)

    extraction model id on the router. Must be non-reasoning (see the class header).

  • embedder_model (String)

    embedder model id on the router.

  • container_router_url (String, nil) (defaults to: nil)

    escape hatch: router base URL as seen from inside the mem0 container. When set, the relay is not spawned and routing the container to this URL is the caller's problem (e.g. "http://host.docker.internal:8080/v1" on a rootful daemon, or an https router on a routable address). Default nil — use the relay.

  • port (Integer) (defaults to: DEFAULT_PORT)

    host port for the REST API (127.0.0.1).

  • qdrant_port (Integer) (defaults to: DEFAULT_QDRANT_PORT)

    host port for Qdrant (127.0.0.1).

  • embedding_dims (Integer) (defaults to: DEFAULT_EMBEDDING_DIMS)

    embedder output dimension.

  • collection (String) (defaults to: DEFAULT_COLLECTION)

    Qdrant collection name.

  • qdrant_image (String) (defaults to: DEFAULT_QDRANT_IMAGE)

    Qdrant docker image.

  • cache_dir (String, Pathname, nil) (defaults to: nil)

    where the mem0 checkout lives. nil resolves to #default_cache_dir.

  • healthcheck_timeout (Integer) (defaults to: DEFAULT_HEALTHCHECK_TIMEOUT)

    seconds to poll readiness.

  • connection (Faraday::Connection, nil) (defaults to: nil)

    DI hook for tests.

Raises:

  • (ArgumentError)

    when router_url is not http:// and no container_router_url is given (the TCP relay can't originate TLS).



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/pikuri/memory/mem0_server.rb', line 188

def initialize(router_url:, llm_model:, embedder_model:, container_router_url: nil,
               port: DEFAULT_PORT, qdrant_port: DEFAULT_QDRANT_PORT,
               embedding_dims: DEFAULT_EMBEDDING_DIMS, collection: DEFAULT_COLLECTION,
               qdrant_image: DEFAULT_QDRANT_IMAGE, cache_dir: nil,
               healthcheck_timeout: DEFAULT_HEALTHCHECK_TIMEOUT, connection: nil)
  @router_url = router_url
  @container_router_url = container_router_url
  if container_router_url.nil? && URI(router_url).scheme != 'http'
    raise ArgumentError, "Mem0Server: the socat relay carries plain TCP, so router_url must be " \
                         "http:// (got #{router_url.inspect}). For an https router, pass " \
                         'container_router_url: with an address the container can route to.'
  end
  @llm_model = llm_model
  @embedder_model = embedder_model
  @port = port
  @qdrant_port = qdrant_port
  @embedding_dims = embedding_dims
  @collection = collection
  @qdrant_image = qdrant_image
  @cache_dir = Pathname.new(cache_dir || default_cache_dir).expand_path
  @healthcheck_timeout = healthcheck_timeout
  @connection = connection
  @closed = false
  @finalizer_handle = nil
  @relay = nil
end

Instance Attribute Details

#portInteger (readonly)

Returns host port the REST API binds.

Returns:

  • (Integer)

    host port the REST API binds.



216
217
218
# File 'lib/pikuri/memory/mem0_server.rb', line 216

def port
  @port
end

Class Method Details

.ensure_running(**kwargs) ⇒ Mem0Server

Construct and immediately ensure the stack is running. Convenience factory — new(...).tap(&:ensure_running!).

Returns:



156
157
158
# File 'lib/pikuri/memory/mem0_server.rb', line 156

def self.ensure_running(**kwargs)
  new(**kwargs).tap(&:ensure_running!)
end

Instance Method Details

#checkout_dirPathname

Returns the mem0 source checkout (the image build context). Under temp/ — it is a regenerable clone, not data.

Returns:

  • (Pathname)

    the mem0 source checkout (the image build context). Under temp/ — it is a regenerable clone, not data.



220
221
222
# File 'lib/pikuri/memory/mem0_server.rb', line 220

def checkout_dir
  @cache_dir.join('temp', 'git')
end

#clientMem0Client

Build a Pikuri::Memory::Mem0Client pointed at the supervised server.

Returns:



248
249
250
# File 'lib/pikuri/memory/mem0_server.rb', line 248

def client
  Mem0Client.new(endpoint: endpoint, connection: @connection)
end

#closevoid

This method returns an undefined value.

Stop the stack (+docker compose down+), leaving #data_dir's bind-mounted host directories — the Qdrant corpus and memory history survive. Registered with Finalizers by #ensure_running!; safe to call directly. Best-effort and idempotent: a non-zero down is logged, not raised (teardown shouldn't abort on an already-gone stack).



290
291
292
293
294
295
296
297
298
299
# File 'lib/pikuri/memory/mem0_server.rb', line 290

def close
  return if @closed

  @closed = true
  result = compose('down')
  stop_relay!
  return if result.status.success?

  LOGGER.warn("docker compose down failed (exit #{result.status.exitstatus}): #{result.output.strip}")
end

#data_dirPathname

Returns host directory bind-mounted into the containers for persistent state — data/qdrant holds the Qdrant corpus, data/history the memory-history SQLite. Survives the ephemeral containers.

Returns:

  • (Pathname)

    host directory bind-mounted into the containers for persistent state — data/qdrant holds the Qdrant corpus, data/history the memory-history SQLite. Survives the ephemeral containers.



228
229
230
# File 'lib/pikuri/memory/mem0_server.rb', line 228

def data_dir
  @cache_dir.join('data')
end

#default_cache_dirString

Default cache root for this supervisor: <Pikuri::Paths.cache>/mem0 (+$XDG_CACHE_HOME/pikuri/mem0+ or ~/.cache/pikuri/mem0), holding temp/git (the checkout) and data/ (the bind-mounted corpus + history).

Returns:

  • (String)


307
308
309
# File 'lib/pikuri/memory/mem0_server.rb', line 307

def default_cache_dir
  Pikuri::Paths.cache.join('mem0').to_s
end

#endpointString

Returns "http://localhost:<port>".

Returns:

  • (String)

    "http://localhost:<port>".



241
242
243
# File 'lib/pikuri/memory/mem0_server.rb', line 241

def endpoint
  "http://localhost:#{@port}"
end

#ensure_running!void

This method returns an undefined value.

Idempotent: ensure the checkout exists + is patched, bring the compose stack up (building the image on first run), heartbeat-poll the REST API until ready, then register #close with Finalizers so the stack is stopped at process exit.

Raises:

  • (RuntimeError)

    on missing docker/git, clone/patch failure, compose up failure, or healthcheck timeout.



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/pikuri/memory/mem0_server.rb', line 260

def ensure_running!
  prepare_checkout!
  # Pre-create the bind-mount sources so docker doesn't create them
  # root-owned (and so a missing dir isn't silently a fresh volume).
  FileUtils.mkdir_p(data_dir.join('qdrant'))
  FileUtils.mkdir_p(data_dir.join('history'))
  FileUtils.mkdir_p(sock_dir, mode: 0o700)
  start_relay! unless @container_router_url
  begin
    LOGGER.info("starting #{COMPOSE_PROJECT} (mem0 + Qdrant + relay) on 127.0.0.1:#{@port}; " \
                'first run builds the mem0 image and may take a few minutes')
    compose!('up', '-d', '--build')
    wait_for_healthy!
  rescue StandardError
    # Self-heal: don't leave the relay as a stray when the stack
    # never came up (same discipline as Agent's build-phase rescue).
    stop_relay!
    raise
  end
  register_for_cleanup
end

#sock_dirPathname

Returns host directory holding the relay's unix socket, bind-mounted into the router-proxy sidecar. The directory is what's mounted — a restarted host-side socat re-creates the socket file, and a file bind-mount would pin the stale inode.

Returns:

  • (Pathname)

    host directory holding the relay's unix socket, bind-mounted into the router-proxy sidecar. The directory is what's mounted — a restarted host-side socat re-creates the socket file, and a file bind-mount would pin the stale inode.



236
237
238
# File 'lib/pikuri/memory/mem0_server.rb', line 236

def sock_dir
  @cache_dir.join('sock')
end