Class: Pikuri::Memory::Mem0Server
- Inherits:
-
Object
- Object
- Pikuri::Memory::Mem0Server
- 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:
-
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).
-
The compose stack’s
router-proxysidecar (a pinned SOCAT_IMAGE, ~5 MB) bind-mounts the socket directory and relays it back onto the stack-internal network ashttp://router-proxy:8080. -
mem0’s
OPENAI_BASE_URLpoints 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.
'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_CONFIGmoved. '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). 'pikuri-internal-mem0'- COMPOSE_FILE =
Returns absolute path to the shipped compose file.
File.('../../../docker/docker-compose.yml', __dir__)
- PATCH_FILE =
Returns absolute path to the shipped DEFAULT_CONFIG pgvector→qdrant patch.
File.('../../../docker/qdrant-default-config.patch', __dir__)
- DEFAULT_PORT =
Returns 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.
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. 768- DEFAULT_COLLECTION =
Returns 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. Seepikuri-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.
60- SOCAT_IMAGE =
Returns pinned socat image for the
router-proxysidecar (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. '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.
5
Instance Attribute Summary collapse
-
#port ⇒ Integer
readonly
Host port the REST API binds.
Class Method Summary collapse
-
.ensure_running(**kwargs) ⇒ Mem0Server
Construct and immediately ensure the stack is running.
Instance Method Summary collapse
-
#checkout_dir ⇒ Pathname
The mem0 source checkout (the image build context).
-
#client ⇒ Mem0Client
Build a Mem0Client pointed at the supervised server.
-
#close ⇒ void
Stop the stack (+docker compose down+), leaving #data_dir‘s bind-mounted host directories — the Qdrant corpus and memory history survive.
-
#data_dir ⇒ Pathname
Host directory bind-mounted into the containers for persistent state —
data/qdrantholds the Qdrant corpus,data/historythe memory-history SQLite. -
#default_cache_dir ⇒ String
Default cache root for this supervisor: <Pikuri::Paths.cache>/mem0 (i.e. $XDG_CACHE_HOME/pikuri/mem0 or ~/.cache/pikuri/mem0).
-
#endpoint ⇒ String
“localhost:<port>”.
-
#ensure_running! ⇒ void
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.
- #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 constructor
-
#sock_dir ⇒ Pathname
Host directory holding the relay’s unix socket, bind-mounted into the
router-proxysidecar.
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
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 = @port = port @qdrant_port = qdrant_port @embedding_dims = @collection = collection @qdrant_image = qdrant_image @cache_dir = Pathname.new(cache_dir || default_cache_dir). @healthcheck_timeout = healthcheck_timeout @connection = connection @closed = false @finalizer_handle = nil @relay = nil end |
Instance Attribute Details
#port ⇒ Integer (readonly)
Returns 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!).
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_dir ⇒ Pathname
Returns 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 |
#client ⇒ Mem0Client
Build a Pikuri::Memory::Mem0Client pointed at the supervised server.
279 280 281 |
# File 'lib/pikuri/memory/mem0_server.rb', line 279 def client Mem0Client.new(endpoint: endpoint, connection: @connection) end |
#close ⇒ void
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_dir ⇒ Pathname
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).
259 260 261 |
# File 'lib/pikuri/memory/mem0_server.rb', line 259 def data_dir @cache_dir.join('data') end |
#default_cache_dir ⇒ String
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.
340 341 342 |
# File 'lib/pikuri/memory/mem0_server.rb', line 340 def default_cache_dir Pikuri::Paths.cache.join('mem0').to_s end |
#endpoint ⇒ String
Returns “localhost:<port>”.
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.
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_dir ⇒ Pathname
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.
267 268 269 |
# File 'lib/pikuri/memory/mem0_server.rb', line 267 def sock_dir @cache_dir.join('sock') end |