Class: Rubino::Memory::Store

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/memory/store.rb

Overview

Primary storage interface for persistent memories. Handles CRUD operations on the memories table.

Defined Under Namespace

Classes: BudgetExceededError, ThreatDetectedError

Constant Summary collapse

VALID_KINDS =
%w[
  user_profile
  preference
  project_context
  technical_decision
  fact
  task_state
  tool_result
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(db: nil, config: nil) ⇒ Store

Returns a new instance of Store.



50
51
52
53
# File 'lib/rubino/memory/store.rb', line 50

def initialize(db: nil, config: nil)
  @db = db || Rubino.database.db
  @config = config
end

Class Method Details

.group_for_kind(kind) ⇒ Object

Returns the budget group a kind belongs to:

  • “user” → user_profile (its own dedicated budget)

  • “memory” → everything else (shared general-memory budget)



161
162
163
# File 'lib/rubino/memory/store.rb', line 161

def self.group_for_kind(kind)
  kind == "user_profile" ? "user" : "memory"
end

Instance Method Details

#by_kind(kind, limit: 50) ⇒ Object

Returns memories of a specific kind



126
127
128
129
130
131
132
# File 'lib/rubino/memory/store.rb', line 126

def by_kind(kind, limit: 50)
  @db[:memories]
    .where(kind: kind)
    .order(Sequel.desc(:confidence), Sequel.desc(:created_at))
    .limit(limit)
    .all
end

#countObject

Returns the total count of stored memories



154
155
156
# File 'lib/rubino/memory/store.rb', line 154

def count
  @db[:memories].count
end

#create(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {}) ⇒ Object

Creates a new memory entry.

Two boundary checks run before the row is inserted:

1. ThreatScanner — refuses prompt-injection, exfil, invisible
   unicode, etc. Memory persists across sessions, so a tainted
   write would keep biasing every future prompt.
2. char-budget — refuses writes that would push the group's
   total past memory_char_limit / memory_user_char_limit. Lets
   callers (Tools::MemoryTool) surface a "delete or replace
   older entries first" message instead of silently truncating
   at read-time.


66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/rubino/memory/store.rb', line 66

def create(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
  validate_kind!(kind)
  enforce_threat_scan!(content)
  enforce_char_budget!(kind, content)

  now = Time.now.utc.iso8601
  id = SecureRandom.uuid

  @db[:memories].insert(
    id: id,
    kind: kind,
    content: content,
    source_session_id: source_session_id,
    confidence: confidence,
    metadata_json: .empty? ? nil : JSON.generate(),
    created_at: now,
    updated_at: now
  )

  find(id)
end

#delete(id) ⇒ Object

Deletes a memory



120
121
122
123
# File 'lib/rubino/memory/store.rb', line 120

def delete(id)
  count = @db[:memories].where(Sequel.like(:id, "#{id}%")).delete
  count > 0
end

#find(id) ⇒ Object

Finds a memory by ID (supports prefix matching)



89
90
91
# File 'lib/rubino/memory/store.rb', line 89

def find(id)
  @db[:memories].where(Sequel.like(:id, "#{id}%")).first
end

#list(kind: nil, limit: 20) ⇒ Object

Lists memories with optional filters



94
95
96
97
98
# File 'lib/rubino/memory/store.rb', line 94

def list(kind: nil, limit: 20)
  dataset = @db[:memories].order(Sequel.desc(:created_at)).limit(limit)
  dataset = dataset.where(kind: kind) if kind
  dataset.all
end

#total_chars_for_group(group) ⇒ Object

Sum of content length across every row in the given group.



166
167
168
169
170
171
172
# File 'lib/rubino/memory/store.rb', line 166

def total_chars_for_group(group)
  if group == "user"
    @db[:memories].where(kind: "user_profile").sum(Sequel.function(:length, :content)).to_i
  else
    @db[:memories].exclude(kind: "user_profile").sum(Sequel.function(:length, :content)).to_i
  end
end

#update(id, content:, confidence: nil) ⇒ Object

Updates a memory’s content.

Same two boundary checks as create — the replace path was a hole that let an agent rewrite a benign entry with prompt-injection / exfil content without going through ThreatScanner, and let a chain of replaces grow a group past its char budget one byte at a time. The budget check subtracts the old row’s length before re-adding the new, otherwise a same-size edit would be flagged as over budget when it isn’t.



109
110
111
112
113
114
115
116
117
# File 'lib/rubino/memory/store.rb', line 109

def update(id, content:, confidence: nil)
  existing = find(id)
  enforce_threat_scan!(content)
  enforce_char_budget_for_update!(existing, content) if existing

  attrs = { content: content, updated_at: Time.now.utc.iso8601 }
  attrs[:confidence] = confidence if confidence
  @db[:memories].where(id: id).update(attrs)
end

#within_limit(char_limit:) ⇒ Object

Returns all memories within the character limit



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

def within_limit(char_limit:)
  memories = @db[:memories]
             .order(Sequel.desc(:confidence), Sequel.desc(:updated_at))
             .all

  selected = []
  total_chars = 0

  memories.each do |m|
    break if total_chars + m[:content].length > char_limit

    selected << m
    total_chars += m[:content].length
  end

  selected
end