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. Pairs with Mem0Client: this class owns the stack (clone + patch the mem0 source, docker compose up –build, heartbeat-poll, tear down); Mem0Client owns the HTTP client that talks to it. #client returns a Mem0Client pre-pointed at the running server.

Same split, and the same “let pikuri manage it” vs “bring your own” choice, as pikuri-vectordb‘s VectorDb::Server::Chroma / VectorDb::Backend::Chroma. A host already running mem0 elsewhere skips this class and wires Mem0Client.new(endpoint:) directly.

Why a compose stack, not a single docker run

Unlike Chroma (one clean published image), mem0’s REST server has no usable published image — the one on Docker Hub is stale (pre-v3) — so it is *built from source*, and it needs Qdrant as a second container. Two interdependent services with a build step is exactly what compose models, and it is mem0 upstream’s own blessed run path. So this supervisor wraps docker compose (through Subprocess.spawn) over the compose file shipped in the gem’s docker/ directory, rather than reimplementing service ordering by hand.

No Postgres; Qdrant, patched in at build

The upstream server’s DEFAULT_CONFIG hardcodes the pgvector vector store, whose mem0 provider has a top-k inversion bug (cosine distance ranked as a similarity — DESIGN.md §“Root cause: the pgvector top-k inversion”), and whose provider connects to Postgres eagerly at boot. #prepare_checkout! applies docker/qdrant-default-config.patch to the pinned checkout, swapping that default to Qdrant (env-driven host/port/dims). Result: correct nearest-first ranking and no Postgres in the stack — verified end-to-end (server boots Postgres-free; add + search through the REST API rank correctly against the local llama.cpp router).

Local LLM + embedder via the router

mem0’s bundled provider validation accepts only openai / anthropic / gemini for the LLM and openai / gemini for the 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 accepted.

The router relay (container → host loopback)

The extraction LLM + embedder calls originate inside the mem0 container, but the llama.cpp router conventionally binds the host’s 127.0.0.1:8080 — an address rootless docker deliberately refuses to route containers to (--disable-host-loopback; re-enabling it daemon-wide would hand every container, including deliberately untrusted MCP containers, a path to every loopback-bound service on the host: CUPS, trust-auth Postgres, an unauthenticated Redis…). Instead of punching that hole, the supervisor builds a scoped one:

  1. The host side runs socat UNIX-LISTEN:<sock_dir>/router.sock,…TCP:<router host:port> — spawned via Subprocess.spawn as a daemon child (never #waited; stopped with Subprocess#terminate, and swept by the exit reaper as a backstop).

  2. The compose stack’s router-proxy sidecar (a pinned SOCAT_IMAGE, ~5 MB) bind-mounts the socket directory and relays it back 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 can reach the router, the host’s loopback stays sealed for everything else, and the same wiring works identically on rootful and rootless daemons — so there is no daemon-flavour special case. The relay speaks plain TCP, so router_url must be http://; an https router needs container_router_url: (which bypasses the relay and is then the caller’s routing problem).

Pinned + patched checkout

The mem0 server is built from MEM0_REF (a pinned commit), cloned into the cache dir 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 building a wrong image (same discipline as the no-think build patch in DESIGN.md §“The no-think patch (fallback): verified live”).

Bind 127.0.0.1

The shipped compose publishes both ports on 127.0.0.1 only — the user’s memory never listens on a routable interface, same posture as VectorDb::Server::Chroma.

Subprocess seam

Every git / docker invocation routes through Subprocess.spawn per the subprocess seam. Errors at boot (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. Prefix pikuri-internal- is the namespace pikuri squats for self-managed infra (same convention as VectorDb::Server::Chroma).

Returns:

  • (String)

    compose project name. Prefix pikuri-internal- is the namespace pikuri squats for self-managed infra (same convention as VectorDb::Server::Chroma).

'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, so a host running both stacks holds one image on disk. The gems don’t depend on each other, so the pin is a convention, not a shared constant; bump both together. See pikuri-vectordb/DESIGN.md §“Verdict”.

Returns:

  • (String)

    pinned Qdrant image. **Kept in lockstep with Pikuri::VectorDb::Server::Qdrant::IMAGE** — same tag, so a host running both stacks holds one image on disk. The gems don’t depend on each other, so the pin is a convention, not a shared constant; bump both together. See pikuri-vectordb/DESIGN.md §“Verdict”.

'qdrant/qdrant:v1.12.4'
DEFAULT_HEALTHCHECK_TIMEOUT =

Returns seconds to wait for the REST API to answer after compose up returns. The first run also builds the image inside compose up –build (minutes), but that is covered by the blocking build call, not this poll — this only covers container-start readiness.

Returns:

  • (Integer)

    seconds to wait for the REST API to answer after compose up returns. The first run also builds the image inside compose up –build (minutes), but that is covered by the blocking build call, not this poll — this only covers container-start readiness.

60
SOCAT_IMAGE =

Returns pinned socat image for the router-proxy sidecar (see the class header’s “router relay” section). A ~5 MB single-binary image; bumped manually like DEFAULT_QDRANT_IMAGE.

Returns:

  • (String)

    pinned socat image for the router-proxy sidecar (see the class header’s “router relay” section). A ~5 MB single-binary image; bumped manually like DEFAULT_QDRANT_IMAGE.

'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 after spawn. Binding is immediate in practice; the timeout exists to fail loud when socat dies on startup (bad address, port typo) instead of surfacing minutes later as silent extraction failures.

Returns:

  • (Integer)

    seconds to wait for the host-side socat to bind the relay socket after spawn. Binding is immediate in practice; the timeout exists to fail loud when socat dies on startup (bad address, port typo) instead of surfacing minutes 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. 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. 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).



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/pikuri/memory/mem0_server.rb', line 218

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.



246
247
248
# File 'lib/pikuri/memory/mem0_server.rb', line 246

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:



186
187
188
# File 'lib/pikuri/memory/mem0_server.rb', line 186

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.



250
251
252
# File 'lib/pikuri/memory/mem0_server.rb', line 250

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

#clientMem0Client

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

Returns:



279
280
281
# File 'lib/pikuri/memory/mem0_server.rb', line 279

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).



321
322
323
324
325
326
327
328
329
330
# File 'lib/pikuri/memory/mem0_server.rb', line 321

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. The containers are ephemeral; this survives them (same posture as VectorDb::Server::Chroma).

Returns:

  • (Pathname)

    host directory bind-mounted into the containers for persistent state — data/qdrant holds the Qdrant corpus, data/history the memory-history SQLite. The containers are ephemeral; this survives them (same posture as VectorDb::Server::Chroma).



259
260
261
# File 'lib/pikuri/memory/mem0_server.rb', line 259

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

#default_cache_dirString

Default cache root for this supervisor: <Pikuri::Paths.cache>/mem0 (i.e. $XDG_CACHE_HOME/pikuri/mem0 or ~/.cache/pikuri/mem0). Holds temp/git (the checkout) and data/ (the bind-mounted corpus + history). Shares the cache root with VectorDb::Server::Chroma via Paths. Public so tests and docs reference the same path the supervisor resolves.

Returns:

  • (String)


340
341
342
# File 'lib/pikuri/memory/mem0_server.rb', line 340

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

#endpointString

Returns localhost:<port>”.

Returns:



272
273
274
# File 'lib/pikuri/memory/mem0_server.rb', line 272

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.



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/pikuri/memory/mem0_server.rb', line 291

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.



267
268
269
# File 'lib/pikuri/memory/mem0_server.rb', line 267

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