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).
  • system_prompt_snippets contributes the always-in-prompt persona summary (tier 1) when resident_persona: is on — re-pulled (refreshed from the store) on every Agent#clear_conversation.
  • on_user_message does tier 2 (automatic prefetch) and the asynchronous capture: enqueue the turn for off-path extraction, then return a <memory-context> slice the Agent injects as a :system message.
  • bind starts the capture worker and arms its bounded flush on agent close.

Read sync, write async: prefetch is on-path (a vector search is milliseconds), capture is off-path through Recorder (extraction is a ~3s LLM call). Recalled context is +:system+-role and only the user's own words are captured — the two halves of the feedback-loop defense (DESIGN.md §"Retrieval").

Automatic capture + recall are safe only on a no-untrusted-ingest, no-egress agent (the @private configuration; Pikuri::Memory namespace header). Sub-agents don't inherit extensions, so a delegated persona's turns are never prefetched or captured.

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 (the first Mem0Client#get_all rows).

Returns:

  • (Integer)

    default cap on the resident-persona summary — a few facts, not the whole store (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 reliable built-in prompt. The bundled memory-extraction prompt is a WIP that under-extracts on small models, so it is opt-in until hardened (DESIGN.md §"Open follow-ups"). The user-only extraction discipline is independent of it — enforced in Mem0Client#add, whatever 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)


85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/pikuri/memory/extension.rb', line 85

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
  @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.



107
108
109
# File 'lib/pikuri/memory/extension.rb', line 107

def recorder
  @recorder
end

Instance Method Details

#bind(ctx) ⇒ 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::ExtensionContext#on_close (not Configurator#on_close) so the lifetime tracks the live agent.

Parameters:

  • ctx (Pikuri::Agent::ExtensionContext)


150
151
152
153
154
# File 'lib/pikuri/memory/extension.rb', line 150

def bind(ctx)
  @recorder.start
  ctx.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)


116
117
118
119
120
121
122
123
124
125
# File 'lib/pikuri/memory/extension.rb', line 116

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)
  nil
end

#on_user_message(_ctx, 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:

  • ctx (Pikuri::Agent::ExtensionContext)

    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.



166
167
168
169
# File 'lib/pikuri/memory/extension.rb', line 166

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

#system_prompt_snippetsArray<String>

The resident-persona summary, freshly fetched from the store. Because the Agent re-pulls this on every clear, the persona refreshes across a "/clear" — facts captured during the cleared conversation show up, instead of staying frozen at construction. Returns [] when the resident persona is off, the store is empty, or (via #resident_persona_snippet's own rescue) the store is unreachable this pass.

Returns:

  • (Array<String>)


136
137
138
139
140
141
# File 'lib/pikuri/memory/extension.rb', line 136

def system_prompt_snippets
  return [] unless @resident_persona

  snippet = resident_persona_snippet
  snippet ? [snippet] : []
end