Class: Rubino::Memory::Store
- Inherits:
-
Object
- Object
- Rubino::Memory::Store
- 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
-
.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).
Instance Method Summary collapse
-
#by_kind(kind, limit: 50) ⇒ Object
Returns memories of a specific kind.
-
#count ⇒ Object
Returns the total count of stored memories.
-
#create(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {}) ⇒ Object
Creates a new memory entry.
-
#delete(id) ⇒ Object
Deletes a memory — exact id, or an unambiguous prefix; a blank id deletes NOTHING (#416).
-
#find(id) ⇒ Object
Finds a memory by ID (exact match, or an unambiguous prefix).
-
#initialize(db: nil, config: nil) ⇒ Store
constructor
A new instance of Store.
-
#list(kind: nil, limit: 20) ⇒ Object
Lists memories with optional filters.
-
#resolve_row(id) ⇒ Object
Resolve a caller-supplied id to AT MOST ONE row.
-
#total_chars_for_group(group) ⇒ Object
Sum of content length across every row in the given group.
-
#update(id, content:, confidence: nil) ⇒ Object
Updates a memory’s content.
-
#within_limit(char_limit:) ⇒ Object
Returns all memories within the character limit.
Constructor Details
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)
189 190 191 |
# File 'lib/rubino/memory/store.rb', line 189 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
154 155 156 157 158 159 160 |
# File 'lib/rubino/memory/store.rb', line 154 def by_kind(kind, limit: 50) @db[:memories] .where(kind: kind) .order(Sequel.desc(:confidence), Sequel.desc(:created_at)) .limit(limit) .all end |
#count ⇒ Object
Returns the total count of stored memories
182 183 184 |
# File 'lib/rubino/memory/store.rb', line 182 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 87 88 89 90 91 92 93 |
# File 'lib/rubino/memory/store.rb', line 66 def create(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {}) validate_kind!(kind) # Coerce to clean, persistable UTF-8 (valid encoding + no NUL) at the # write seam (R4-N3): a NUL in fact content makes the SQLite3 driver # raise "unrecognized token" (the row never persists), and a non-UTF-8 # byte breaks the JSON-tagged metadata path — both leave the fact lost. # Defense-in-depth: today's writers are model-mediated, but a future # extractor that pipes raw tool/file bytes into a fact would wedge here. content = Util::Output.scrub_utf8(content) 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 — exact id, or an unambiguous prefix; a blank id deletes NOTHING (#416). Resolves to a single row first so a bare/short prefix can never mass-delete via a ‘%` LIKE.
146 147 148 149 150 151 |
# File 'lib/rubino/memory/store.rb', line 146 def delete(id) row = resolve_row(id) return false unless row @db[:memories].where(id: row[:id]).delete.positive? end |
#find(id) ⇒ Object
Finds a memory by ID (exact match, or an unambiguous prefix)
96 97 98 |
# File 'lib/rubino/memory/store.rb', line 96 def find(id) resolve_row(id) end |
#list(kind: nil, limit: 20) ⇒ Object
Lists memories with optional filters
118 119 120 121 122 |
# File 'lib/rubino/memory/store.rb', line 118 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 |
#resolve_row(id) ⇒ Object
Resolve a caller-supplied id to AT MOST ONE row. A blank id resolves to nothing — a bare-prefix LIKE on “” matched the ‘%` wildcard → EVERY row, so `memory delete “”` deleted the whole store and reported success (data loss, #416). An EXACT id always wins; a non-empty prefix is accepted ONLY when unambiguous (matches exactly one row), so a short id from `memory list` still resolves but a 1-char prefix can never mass-select.
106 107 108 109 110 111 112 113 114 115 |
# File 'lib/rubino/memory/store.rb', line 106 def resolve_row(id) key = id.to_s return nil if key.strip.empty? exact = @db[:memories].where(id: key).first return exact if exact matches = @db[:memories].where(Sequel.like(:id, "#{key}%")).limit(2).all matches.size == 1 ? matches.first : nil end |
#total_chars_for_group(group) ⇒ Object
Sum of content length across every row in the given group.
194 195 196 197 198 199 200 |
# File 'lib/rubino/memory/store.rb', line 194 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.
133 134 135 136 137 138 139 140 141 |
# File 'lib/rubino/memory/store.rb', line 133 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
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/rubino/memory/store.rb', line 163 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 |