Class: Ask::Agent::Memory

Inherits:
Object
  • Object
show all
Defined in:
lib/ask/agent/memory.rb

Overview

Durable, namespaced memory on any State::Adapter — the same storage layer as sessions and checkpoints.

Entries are plain facts ("the deploy window is Tuesday", "the user prefers concise answers") that outlive a session: session A writes them, session B (same adapter + namespace) retrieves them via keyword search and has them injected into context. The abstraction is domain-agnostic — nothing here assumes a coding agent.

Storage shape (pure KV, no list primitives — works with every backend including custom get/set/delete adapters):

memory:<namespace>:<id>      — one key per entry
memory:<namespace>:index     — JSON array of entry ids (write order)

store = Ask::State::Providers::SQLite.new(path: "agent.db")
memory = Ask::Agent::Memory.new(state: store, namespace: "user:42")
memory.write("Deploy window is Tuesday")
memory.search("when can we deploy?")   # => [Entry]

Namespaces isolate memory: a support agent's facts never leak into a finance agent's, and tenants share one backend safely.

Defined Under Namespace

Classes: Entry

Constant Summary collapse

KEY_PREFIX =
"memory:"
INDEX_SUFFIX =
":index"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(state:, namespace:, max_entries: nil) ⇒ Memory

Returns a new instance of Memory.

Parameters:

  • state (Ask::State::Adapter)

    backing store

  • namespace (String)

    isolation scope (user id, project id, ...)

  • max_entries (Integer, nil) (defaults to: nil)

    when set, the oldest entries are pruned once the namespace exceeds this many entries



43
44
45
46
47
48
# File 'lib/ask/agent/memory.rb', line 43

def initialize(state:, namespace:, max_entries: nil)
  @state = state
  @namespace = namespace.to_s
  @max_entries = max_entries
  @mutex = Monitor.new
end

Instance Attribute Details

#namespaceString (readonly)

Returns the namespace this memory is scoped to.

Returns:

  • (String)

    the namespace this memory is scoped to



54
55
56
# File 'lib/ask/agent/memory.rb', line 54

def namespace
  @namespace
end

#stateAsk::State::Adapter (readonly)

Returns the underlying adapter.

Returns:

  • (Ask::State::Adapter)

    the underlying adapter



51
52
53
# File 'lib/ask/agent/memory.rb', line 51

def state
  @state
end

Instance Method Details

#countInteger

Returns number of entries in this namespace.

Returns:

  • (Integer)

    number of entries in this namespace



117
118
119
# File 'lib/ask/agent/memory.rb', line 117

def count
  entries.size
end

#delete(id) ⇒ void

This method returns an undefined value.

Remove an entry by id.

Parameters:

  • id (String)


108
109
110
111
112
113
114
# File 'lib/ask/agent/memory.rb', line 108

def delete(id)
  @mutex.synchronize do
    @state.delete(entry_key(id))
    @state.set(index_key, (load_index - [id]).to_json)
  end
  nil
end

#list(limit: 50) ⇒ Array<Entry>

Returns entries, newest first.

Parameters:

  • limit (Integer) (defaults to: 50)

Returns:

  • (Array<Entry>)

    entries, newest first



100
101
102
# File 'lib/ask/agent/memory.rb', line 100

def list(limit: 50)
  entries.last(limit).reverse
end

#search(query, limit: 5) ⇒ Array<Entry>

Keyword search over the namespace's entries: entries matching any query term (case-insensitive substring), ranked by matched-term count, newest first on ties.

Parameters:

  • query (String)
  • limit (Integer) (defaults to: 5)

    max results

Returns:



86
87
88
89
90
91
92
93
94
95
96
# File 'lib/ask/agent/memory.rb', line 86

def search(query, limit: 5)
  terms = query.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").split(/\s+/).reject(&:empty?)
  return [] if terms.empty?

  scored = entries.filter_map do |entry|
    text = entry.content.downcase
    hits = terms.count { |term| text.include?(term) }
    [hits, entry] if hits.positive?
  end
  scored.sort_by { |hits, entry| [-hits, entry.created_at] }.first(limit).map(&:last)
end

#write(content, metadata: {}) ⇒ Entry

Save a fact. Writing an identical content again is a no-op (returns the existing entry).

Parameters:

  • content (String)

    the fact to remember

  • metadata (Hash) (defaults to: {})

    optional provenance (session id, tags, ...)

Returns:

Raises:

  • (ArgumentError)

    on empty content



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/ask/agent/memory.rb', line 63

def write(content, metadata: {})
  content = content.to_s
  raise ArgumentError, "content is required" if content.strip.empty?

  @mutex.synchronize do
    existing = entries.find { |e| e.content == content }
    return existing if existing

    entry = Entry.new(id: SecureRandom.uuid, content: content, metadata: , created_at: Time.now)
    @state.set(entry_key(entry.id), entry.to_h)
    @state.set(index_key, (load_index + [entry.id]).to_json)
    prune_oldest if @max_entries
    entry
  end
end