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). Hand-rolled rather than depending on a mem0 Ruby SDK (there isn't a maintained one), so the wire protocol stays auditable in one readable file.
Bring your own server
Mem0Client.new(endpoint:) points at an already-running mem0 server
(let Mem0Server start one, or bring a shared deployment). The
server must be configured for the 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 (Qdrantscore= 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.
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 — DESIGN.md §"Extraction-input discipline"). The rule lives
in the 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 — each caller does (observation, log-and-drop, or
inject-nothing).
Constant Summary collapse
- LOGGER =
Pikuri.logger_for('Memory::Mem0Client')
- DEFAULT_ENDPOINT =
Returns default mem0 server base URL — the server's own
:8000on localhost. Pikuri::Memory::Mem0Server publishes8888->8000, so a host using it passes that port explicitly. 'http://localhost:8000'- DEFAULT_TIMEOUT =
Returns default per-request read timeout, in seconds. Generous on purpose: the first
POST /memories//searchon a fresh stack blocks on the router cold-loading the extraction / embedder model, a one-off wait well past net_http's stock ~60s. Steady-state extraction is ~3s, so this ceiling only bites 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
81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/pikuri/memory/mem0_client.rb', line 81 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.
95 96 97 |
# File 'lib/pikuri/memory/mem0_client.rb', line 95 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").
116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/pikuri/memory/mem0_client.rb', line 116 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".
190 191 192 193 194 195 196 197 198 199 |
# File 'lib/pikuri/memory/mem0_client.rb', line 190 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.
169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/pikuri/memory/mem0_client.rb', line 169 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.
207 208 209 210 |
# File 'lib/pikuri/memory/mem0_client.rb', line 207 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".
151 152 153 154 155 156 157 158 159 160 |
# File 'lib/pikuri/memory/mem0_client.rb', line 151 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 |