Class: Pikuri::Memory::Mem0Client
- Inherits:
-
Object
- Object
- Pikuri::Memory::Mem0Client
- 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
:8000on 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 (Recorderlogs-and-drops). Steady-state extraction is ~3s, so this ceiling only ever bites on the cold path or a genuine hang. Override withPIKURI_MEMORY_TIMEOUTor thetimeout:kwarg. 300
Instance Attribute Summary collapse
-
#endpoint ⇒ String
readonly
The mem0 server base URL this client targets.
Instance Method Summary collapse
-
#add(content:, user_id:, infer: true, prompt: nil) ⇒ Array<Record>
Append a memory.
-
#delete(id:) ⇒ void
Granular erase: physically remove one memory by id (the privacy right-to-forget, user-gated at the caller).
-
#get_all(user_id:) ⇒ Array<Record>
Every memory stored for
user_id, in mem0’s order. - #initialize(endpoint: DEFAULT_ENDPOINT, timeout: DEFAULT_TIMEOUT, connection: nil) ⇒ Mem0Client constructor
-
#reset! ⇒ void
Coarse erase: drop all memories on the server.
-
#search(query:, user_id:, top_k: 5, threshold: nil) ⇒ Array<Record>
Semantic recall.
Constructor Details
#initialize(endpoint: DEFAULT_ENDPOINT, timeout: DEFAULT_TIMEOUT, connection: nil) ⇒ Mem0Client
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..timeout = @timeout f.adapter Faraday.default_adapter end end |
Instance Attribute Details
#endpoint ⇒ String (readonly)
Returns 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”).
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”.
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.}" 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.
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.}" 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.
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”.
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 |