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)



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

#countObject

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