Class: Pikuri::Memory::Extension

Inherits:
Object
  • Object
show all
Includes:
Agent::Extension
Defined in:
lib/pikuri/memory/extension.rb

Overview

The host-facing API: wire durable cross-conversation memory onto a Agent via c.add_extension inside the Agent.new block — same opt-in shape as pikuri-tasks / pikuri-vectordb.

Usage

client = Pikuri::Memory::Mem0Client.new(endpoint: 'http://localhost:8888')
Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
  c.add_extension Pikuri::Memory::Extension.new(
    client: client, user_id: 'martin'
  )
end

What it wires (the three retrieval tiers)

  • configure registers the recall tool (Recall) and, when resident_persona: is on, appends a small always-in-prompt persona summary read from the store (tier 1 + tier 3).

  • on_user_message (the per-turn hook) does tier 2 — the automatic prefetch — and the asynchronous capture: it enqueues the user’s turn for off-path extraction, then returns a small <memory-context> slice for the Agent to inject as a :system message after the user turn.

  • bind starts the capture worker and arms its bounded flush on agent close.

Read sync, write async

Prefetch runs on the interaction path because a vector search is milliseconds; capture runs off it through Recorder because extraction is a ~3s LLM call. Recalled context is :system-role (provenance-tagged, excluded from the next extraction pass), and only the user’s own words are captured — the two halves of the feedback-loop defense (DESIGN.md §“Retrieval”).

Safety scope

Automatic capture + recall are safe only on a no-untrusted-ingest, no-egress agent (the @private configuration). See the Pikuri::Memory namespace header.

Sub-agents

Sub-agents do not inherit extensions, so a delegated persona’s turns are never prefetched or captured by the parent’s memory —consistent with the no-inherit rule.

Constant Summary collapse

LOGGER =
Pikuri.logger_for('Memory::Extension')
DEFAULT_PREFETCH_K =

Returns default prefetch slice size — small and high-precision, since junk recall degrades behavior and the recall tool means a small slice is a pointer, not a loss (DESIGN.md §“Automatic ≠ always-inject”).

Returns:

  • (Integer)

    default prefetch slice size — small and high-precision, since junk recall degrades behavior and the recall tool means a small slice is a pointer, not a loss (DESIGN.md §“Automatic ≠ always-inject”).

5
DEFAULT_RESIDENT_LIMIT =

Returns default cap on the resident-persona summary —a few facts, not the whole store. Curated synthesis is a follow-up; v1 takes the first Mem0Client#get_all rows.

Returns:

  • (Integer)

    default cap on the resident-persona summary —a few facts, not the whole store. Curated synthesis is a follow-up; v1 takes the first Mem0Client#get_all rows.

20

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client:, user_id:, prefetch_k: DEFAULT_PREFETCH_K, threshold: nil, infer: true, extraction_prompt: nil, resident_persona: true, flush_timeout: Recorder::DEFAULT_FLUSH_TIMEOUT, resident_limit: DEFAULT_RESIDENT_LIMIT) ⇒ Extension

Parameters:

  • client (Mem0Client)

    the mem0 client recall + capture use.

  • user_id (String)

    the mem0 namespace (one per user). All reads and writes are scoped to it.

  • prefetch_k (Integer) (defaults to: DEFAULT_PREFETCH_K)

    max memories injected per turn by the automatic prefetch.

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

    optional similarity floor for prefetch (higher = stricter; Qdrant score is a similarity). nil (default) injects the top prefetch_k ungated — a host should set a calibrated floor once it knows its embedder’s relevant-vs-irrelevant gap, so a bare “thanks!” recalls nothing. Applied both server-side (passed to Mem0Client#search) and client-side (so the contract holds regardless of server behavior).

  • infer (Boolean) (defaults to: true)

    forwarded to capture; true stores extracted facts.

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

    custom_fact_extraction_prompt sent with each capture. nil (default) sends none, so mem0 uses its own built-in extraction prompt — which reliably extracts plain statements. The bundled memory-extraction prompt (+Pikuri.prompt(‘memory-extraction’)+) is a **work in progress**: it tightens junk rejection per the #4573 audit, but on small extraction models it currently under-extracts (returns {“facts”: []} for clear facts), so it is opt-in until hardened — see DESIGN.md §“Open follow-ups”. The user-only extraction discipline does not depend on it; that is enforced in Mem0Client#add (only user-role content is ever sent), regardless of which extraction prompt mem0 runs.

  • resident_persona (Boolean) (defaults to: true)

    when true (default), append a persona summary to the system prompt at construction.

  • flush_timeout (Integer) (defaults to: Recorder::DEFAULT_FLUSH_TIMEOUT)

    seconds the capture worker’s bounded flush waits on agent close.

  • resident_limit (Integer) (defaults to: DEFAULT_RESIDENT_LIMIT)

    max facts in the resident persona summary.

Raises:

  • (ArgumentError)


101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/pikuri/memory/extension.rb', line 101

def initialize(client:, user_id:,
               prefetch_k: DEFAULT_PREFETCH_K, threshold: nil,
               infer: true, extraction_prompt: nil,
               resident_persona: true, flush_timeout: Recorder::DEFAULT_FLUSH_TIMEOUT,
               resident_limit: DEFAULT_RESIDENT_LIMIT)
  raise ArgumentError, 'user_id must be non-empty' if user_id.nil? || user_id.to_s.empty?

  @client = client
  @user_id = user_id
  @prefetch_k = prefetch_k
  @threshold = threshold
  @resident_persona = resident_persona
  @resident_limit = resident_limit
  # nil => mem0's built-in extraction (reliable). The bundled curated
  # prompt is opt-in (a WIP that under-extracts on small models) — see
  # the +extraction_prompt+ param doc.
  @extraction_prompt = extraction_prompt
  @recorder = Recorder.new(
    client: client, user_id: user_id, infer: infer,
    prompt: @extraction_prompt, flush_timeout: flush_timeout
  )
end

Instance Attribute Details

#recorderRecorder (readonly)

Returns the capture queue, exposed for tests and for a host that wants to flush it explicitly.

Returns:

  • (Recorder)

    the capture queue, exposed for tests and for a host that wants to flush it explicitly.



126
127
128
# File 'lib/pikuri/memory/extension.rb', line 126

def recorder
  @recorder
end

Instance Method Details

#bind(agent) ⇒ void

This method returns an undefined value.

Start the capture worker and arm its bounded flush on agent close. Keyed to this specific agent via Agent#on_close (not Configurator#on_close) so the lifetime tracks the live agent.

Parameters:

  • agent (Pikuri::Agent)


157
158
159
160
161
# File 'lib/pikuri/memory/extension.rb', line 157

def bind(agent)
  @recorder.start
  agent.on_close { @recorder.close }
  nil
end

#configure(c) ⇒ void

This method returns an undefined value.

Register the recall tool and (optionally) append the resident persona summary. Raises if recall was pre-registered — the extension is its sole owner and a duplicate would bind to a different client / namespace.

Parameters:

  • c (Pikuri::Agent::Configurator)


135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/pikuri/memory/extension.rb', line 135

def configure(c)
  if c.tools.any? { |t| t.name == 'recall' }
    raise 'recall cannot be pre-registered (in tools: or via c.add_tool) when adding ' \
          'Pikuri::Memory::Extension — the extension owns the recall tool so it shares ' \
          'the same mem0 client / user_id.'
  end

  c.add_tool Recall.new(client: @client, user_id: @user_id)

  return unless @resident_persona

  snippet = resident_persona_snippet
  c.append_system_prompt(snippet) if snippet
  nil
end

#on_user_message(_agent, content) ⇒ String?

Per-turn hook. Enqueues the user’s words for asynchronous capture, then returns the automatic prefetch slice (or nil to inject nothing this turn). Capture happens regardless of whether prefetch finds anything.

Parameters:

  • agent (Pikuri::Agent)

    unused (the namespace is fixed at construction); part of the protocol signature.

  • content (String)

    the incoming user message.

Returns:

  • (String, nil)

    a <memory-context> block, or nil.



172
173
174
175
# File 'lib/pikuri/memory/extension.rb', line 172

def on_user_message(_agent, content)
  @recorder.enqueue(content)
  prefetch(content)
end