Class: Pikuri::Memory::Mem0Client

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

Overview

Thin Faraday HTTP client against a self-hosted mem0 server (the v3 “token-efficient” line — see DESIGN.md). Only the handful of REST endpoints pikuri needs are wired; the client is hand-rolled rather than a dependency on a mem0 Ruby SDK (there isn’t a maintained one), Faraday is already in pikuri-core’s closure, and a thin first-party client keeps the wire protocol auditable in one readable file. Same shape as pikuri-vectordb‘s Backend::Chroma.

Bring your own server

Mem0Client.new(endpoint:) points at an already-running mem0 server (a docker-compose stack, a shared deployment). This release does not ship a supervisor that starts one — that’s the Server::Chroma-style follow-on. The server must be configured for the local stack pikuri assumes: a local OpenAI-compatible LLM + embedder (llama.cpp via openai_base_url), the Qdrant vector backend (the pgvector path has a top-k inversion bug — see DESIGN.md §“Root cause: the pgvector top-k inversion”), and a non-reasoning extraction model. Endpoints used:

  • POST /memories — append. Body { messages:, user_id:, infer:, prompt? }. Returns { “results”: [{ id, memory, event }] }.

  • POST /search — semantic recall. Body { query:, filters: { user_id: }, top_k:, threshold? }. Returns { “results”: [{ id, memory, score, created_at, … }] }, ranked nearest-first (Qdrant score = similarity).

  • GET /memories?user_id= — every memory for a user (search row shape, minus score/event).

  • DELETE /memories/{id} — granular erase.

  • POST /reset — coarse erase (drop everything).

User-role content only (write-side hygiene)

#add takes a single content String and wraps it as one role: “user” message — it cannot send assistant/tool/system turns. That’s deliberate: feeding only the user’s own words to extraction structurally removes the dominant junk sources a production mem0 audit measured (assistant restating, recalled- memory feedback loops, involuntary secret leakage) — see DESIGN.md §“Extraction-input discipline”. The rule lives in the method signature so it can’t be bypassed by accident.

Errors are loud

Non-2xx responses and Faraday transport errors raise RuntimeError with the offending detail. The client doesn’t decide what’s recoverable — its callers do: Recall turns a failure into an “Error: …” observation the LLM can react to, Recorder logs-and-drops so a transient mem0 blip never crashes the capture worker, and Extension‘s prefetch rescues to “inject nothing this turn.”

Constant Summary collapse

LOGGER =
Pikuri.logger_for('Memory::Mem0Client')
DEFAULT_ENDPOINT =

Returns default mem0 server base URL — the server’s own :8000 on localhost. A host running the dev docker-compose (which publishes 8888->8000) passes that port explicitly; the supervisor follow-on will own the mapping.

Returns:

  • (String)

    default mem0 server base URL — the server’s own :8000 on localhost. A host running the dev docker-compose (which publishes 8888->8000) passes that port explicitly; the supervisor follow-on will own the mapping.

'http://localhost:8000'
DEFAULT_TIMEOUT =

Returns default per-request read timeout, in seconds. Deliberately generous: the first POST /memories (and the first /search) on a fresh stack blocks on the local llama.cpp router cold-loading the extraction / embedder model into memory — a one-off wait that can run well past net_http’s stock ~60s before any token comes back. A short timeout there turns a normal cold start into a dropped turn (Recorder logs-and-drops). Steady-state extraction is ~3s, so this ceiling only ever bites on the cold path or a genuine hang. Override with PIKURI_MEMORY_TIMEOUT or the timeout: kwarg.

Returns:

  • (Integer)

    default per-request read timeout, in seconds. Deliberately generous: the first POST /memories (and the first /search) on a fresh stack blocks on the local llama.cpp router cold-loading the extraction / embedder model into memory — a one-off wait that can run well past net_http’s stock ~60s before any token comes back. A short timeout there turns a normal cold start into a dropped turn (Recorder logs-and-drops). Steady-state extraction is ~3s, so this ceiling only ever bites on the cold path or a genuine hang. Override with PIKURI_MEMORY_TIMEOUT or the timeout: kwarg.

300

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(endpoint: DEFAULT_ENDPOINT, timeout: DEFAULT_TIMEOUT, connection: nil) ⇒ Mem0Client

Parameters:

  • endpoint (String) (defaults to: DEFAULT_ENDPOINT)

    mem0 server base URL. /memories, /search, etc. are appended internally.

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT)

    per-request read timeout in seconds (see DEFAULT_TIMEOUT for why it’s large). Resolves as PIKURI_MEMORY_TIMEOUT env → this kwarg → DEFAULT_TIMEOUT.

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

    dependency- injection hook for tests (wire Faraday::Adapter::Test stubs here). Production callers leave it nil; a fresh JSON connection is built against endpoint. When supplied, the timeout kwarg is ignored — the injected connection owns its own options.

Raises:

  • (ArgumentError)

    on a blank endpoint.



97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/pikuri/memory/mem0_client.rb', line 97

def initialize(endpoint: DEFAULT_ENDPOINT, timeout: DEFAULT_TIMEOUT, connection: nil)
  raise ArgumentError, 'endpoint must be non-empty' if endpoint.nil? || endpoint.to_s.empty?

  @endpoint = endpoint
  @timeout = (ENV['PIKURI_MEMORY_TIMEOUT'] || timeout).to_i
  @connection = connection || Faraday.new(url: endpoint) do |f|
    f.request :json
    f.response :json
    f.options.timeout = @timeout
    f.adapter Faraday.default_adapter
  end
end

Instance Attribute Details

#endpointString (readonly)

Returns the mem0 server base URL this client targets.

Returns:

  • (String)

    the mem0 server base URL this client targets.



111
112
113
# File 'lib/pikuri/memory/mem0_client.rb', line 111

def endpoint
  @endpoint
end

Instance Method Details

#add(content:, user_id:, infer: true, prompt: nil) ⇒ Array<Record>

Append a memory. Wraps content as one role: “user” message (the user-only rule — see the class header) and posts it for extraction. mem0 is append-only: a correction is a newer add, never a mutation of an earlier row (DESIGN.md §“Why mem0”).

Parameters:

  • content (String)

    the user’s own words to extract from.

  • user_id (String)

    the mem0 namespace (one per user).

  • infer (Boolean) (defaults to: true)

    true (default) stores LLM-extracted facts; false stores the verbatim turn (the raw-log option, unused in v1).

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

    optional per-request custom_fact_extraction_prompt override. Extension passes the curated memory-extraction prompt here so a BYO server needn’t be reconfigured.

Returns:

  • (Array<Record>)

    the add outcome rows (each carries an event: “ADD” / “UPDATE” / “NONE” / …). Empty when extraction found nothing worth storing.

Raises:

  • (ArgumentError)

    on blank content or user_id.

  • (RuntimeError)

    on HTTP failure.



132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/pikuri/memory/mem0_client.rb', line 132

def add(content:, user_id:, infer: true, prompt: nil)
  raise ArgumentError, 'content must be non-empty' if content.nil? || content.to_s.strip.empty?
  raise ArgumentError, 'user_id must be non-empty' if user_id.nil? || user_id.to_s.empty?

  body = {
    messages: [{ role: 'user', content: content }],
    user_id: user_id,
    infer: infer
  }
  body[:prompt] = prompt if prompt && !prompt.empty?

  results_of(post_json('/memories', body))
end

#delete(id:) ⇒ void

This method returns an undefined value.

Granular erase: physically remove one memory by id (the privacy right-to-forget, user-gated at the caller). Idempotent — a 404 is treated as “already gone”.

Parameters:

Raises:

  • (ArgumentError)

    on blank id.

  • (RuntimeError)

    on HTTP failure other than 404.



206
207
208
209
210
211
212
213
214
215
# File 'lib/pikuri/memory/mem0_client.rb', line 206

def delete(id:)
  raise ArgumentError, 'id must be non-empty' if id.nil? || id.to_s.empty?

  response = @connection.delete("/memories/#{id}")
  return if [200, 204, 404].include?(response.status)

  raise "Memory::Mem0Client: DELETE /memories/#{id} returned HTTP #{response.status}: #{response.body.inspect}"
rescue Faraday::Error => e
  raise "Memory::Mem0Client: #{e.class.name.split('::').last} calling DELETE /memories/#{id}: #{e.message}"
end

#get_all(user_id:) ⇒ Array<Record>

Every memory stored for user_id, in mem0’s order. Backs the resident-persona summary and any future audit dump.

Parameters:

  • user_id (String)

    the mem0 namespace.

Returns:

  • (Array<Record>)

    all rows (no score, no event).

Raises:

  • (ArgumentError)

    on blank user_id.

  • (RuntimeError)

    on HTTP failure.



185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/pikuri/memory/mem0_client.rb', line 185

def get_all(user_id:)
  raise ArgumentError, 'user_id must be non-empty' if user_id.nil? || user_id.to_s.empty?

  response = @connection.get('/memories', { user_id: user_id })
  unless response.status == 200
    raise "Memory::Mem0Client: GET /memories returned HTTP #{response.status}: #{response.body.inspect}"
  end

  results_of(response.body)
rescue Faraday::Error => e
  raise "Memory::Mem0Client: #{e.class.name.split('::').last} calling GET /memories: #{e.message}"
end

#reset!void

This method returns an undefined value.

Coarse erase: drop all memories on the server. The blunt right-to-forget (granular per-fact erase is #delete). Used by tooling / tests, not on the agent path.

Raises:

  • (RuntimeError)

    on HTTP failure.



223
224
225
226
# File 'lib/pikuri/memory/mem0_client.rb', line 223

def reset!
  post_json('/reset', {})
  nil
end

#search(query:, user_id:, top_k: 5, threshold: nil) ⇒ Array<Record>

Semantic recall. Returns at most top_k Records ranked nearest-first (mem0’s Qdrant backend returns a similarity in score; higher = more relevant).

mem0 does not resolve contradictions at read time — it returns the relevant memories (including a stale fact and its correction) near-tied, and the consuming LLM resolves them at synthesis. That’s why Record#created_at rides along. See DESIGN.md §“Supersede recall: resolution is the consumer’s job”.

Parameters:

  • query (String)

    natural-language recall query (typically the latest user message, or a recall topic).

  • user_id (String)

    the mem0 namespace to search.

  • top_k (Integer) (defaults to: 5)

    max rows to return.

  • threshold (Float, nil) (defaults to: nil)

    optional server-side similarity floor. nil lets the server decide; Extension also filters client-side so the contract holds regardless.

Returns:

  • (Array<Record>)

    ranked results; empty on no match.

Raises:

  • (ArgumentError)

    on blank query/user_id or non-positive top_k.

  • (RuntimeError)

    on HTTP failure.



167
168
169
170
171
172
173
174
175
176
# File 'lib/pikuri/memory/mem0_client.rb', line 167

def search(query:, user_id:, top_k: 5, threshold: nil)
  raise ArgumentError, 'query must be non-empty' if query.nil? || query.to_s.strip.empty?
  raise ArgumentError, 'user_id must be non-empty' if user_id.nil? || user_id.to_s.empty?
  raise ArgumentError, "top_k must be positive (got #{top_k})" if top_k <= 0

  body = { query: query, filters: { user_id: user_id }, top_k: top_k }
  body[:threshold] = threshold unless threshold.nil?

  results_of(post_json('/search', body))
end