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. 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:
- Host side:
socat UNIX-LISTEN:<sock_dir>/router.sock,… TCP:<router>, spawned as a daemon child (never +#wait+ed; stopped withSubprocess#terminate, swept by the exit reaper as a backstop). - The compose stack's
router-proxysidecar (pinned SOCAT_IMAGE) bind-mounts the socket directory and relays it 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
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.
'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. 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.
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 → 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 upreturns. Covers container-start readiness only — the first-run image build (minutes) is inside the blocking--buildcall, not this poll. 60- SOCAT_IMAGE =
Returns pinned socat image for the
router-proxysidecar (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. '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.
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(+$XDG_CACHE_HOME/pikuri/mem0+ or~/.cache/pikuri/mem0), holdingtemp/git(the checkout) anddata/(the bind-mounted corpus + history). -
#endpoint ⇒ String
"http://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
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 = @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.
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!).
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_dir ⇒ Pathname
Returns 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 |
#client ⇒ Mem0Client
Build a Pikuri::Memory::Mem0Client pointed at the supervised server.
248 249 250 |
# File 'lib/pikuri/memory/mem0_server.rb', line 248 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).
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_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. 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_dir ⇒ String
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).
307 308 309 |
# File 'lib/pikuri/memory/mem0_server.rb', line 307 def default_cache_dir Pikuri::Paths.cache.join('mem0').to_s end |
#endpoint ⇒ String
Returns "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.
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_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.
236 237 238 |
# File 'lib/pikuri/memory/mem0_server.rb', line 236 def sock_dir @cache_dir.join('sock') end |