Class: Rubino::Session::Store

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

Overview

Persists and queries messages within a session.

Ordering note: created_at is iso8601 with second precision, so multiple messages can share the same timestamp. Read/delete paths that need a strict total order break ties on the SQLite ‘rowid` column.

#last_for_role is the entry point used by retry/undo to find the last user (or assistant) turn before rewinding history.

Instance Method Summary collapse

Constructor Details

#initialize(db: nil) ⇒ Store

Returns a new instance of Store.



16
17
18
# File 'lib/rubino/session/store.rb', line 16

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

Instance Method Details

#append(message) ⇒ Object

Appends a message to a session

Raises:



21
22
23
24
25
26
# File 'lib/rubino/session/store.rb', line 21

def append(message)
  raise SessionError, "Invalid message" unless message.valid?

  @db[:messages].insert(message.to_row)
  message
end

#copy_into(target_session_id, messages) ⇒ Object

Copies messages into another session preserving ALL wire-significant fields. Assistant tool calls live in metadata (not tool_call_id), so dropping metadata orphans the toolUse block and 400s strict providers (Anthropic/Bedrock) on resume. token_count is copied too so the target session’s budget accounting stays accurate.



44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/rubino/session/store.rb', line 44

def copy_into(target_session_id, messages)
  messages.each do |msg|
    create(
      session_id: target_session_id,
      role: msg.role,
      content: msg.content,
      tool_name: msg.tool_name,
      tool_call_id: msg.tool_call_id,
      token_count: msg.token_count,
      metadata: msg.
    )
  end
end

#count(session_id) ⇒ Object

Returns total message count for a session



84
85
86
# File 'lib/rubino/session/store.rb', line 84

def count(session_id)
  @db[:messages].where(session_id: session_id).count
end

#create(session_id:, role:, content:, **attrs) ⇒ Object

Creates and appends a message from attributes



29
30
31
32
33
34
35
36
37
# File 'lib/rubino/session/store.rb', line 29

def create(session_id:, role:, content:, **attrs)
  message = Message.new(
    session_id: session_id,
    role: role,
    content: content,
    **attrs
  )
  append(message)
end

#delete_from_inclusive(session_id, from_id:) ⇒ Integer

Deletes the given message and every message inserted after it. Used by undo/retry to rewind history.

Uses tuple ordering on (created_at, rowid): rows strictly later by timestamp are removed, and ties on created_at are broken by rowid so same-second inserts are still cut at the right point.

Parameters:

  • session_id (String)
  • from_id (String)

    id of the first message to delete

Returns:

  • (Integer)

    number of rows removed



105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/rubino/session/store.rb', line 105

def delete_from_inclusive(session_id, from_id:)
  msg = @db[:messages]
        .where(id: from_id, session_id: session_id)
        .select(:created_at, Sequel.lit("rowid AS row_id"))
        .first
  return 0 unless msg

  @db[:messages]
    .where(session_id: session_id)
    .where(Sequel.lit("(created_at > ?) OR (created_at = ? AND rowid >= ?)",
                      msg[:created_at], msg[:created_at], msg[:row_id]))
    .delete
end

#for_session(session_id, limit: nil) ⇒ Object

Returns all messages for a session in chronological order. created_at is second-precision, so we tie-break on rowid — without this, an assistant preamble and a tool_result persisted in the same second can come back swapped, which makes the resumed transcript look like the tool fired before the model’s preamble (or worse, like an empty assistant box wrapping the tool).



64
65
66
67
68
69
70
# File 'lib/rubino/session/store.rb', line 64

def for_session(session_id, limit: nil)
  dataset = @db[:messages]
            .where(session_id: session_id)
            .order(:created_at, Sequel.lit("rowid"))
  dataset = dataset.limit(limit) if limit
  dataset.all.map { |row| hydrate(row) }
end

#last_for_role(session_id, role) ⇒ Object

Returns the most recent message for ‘role` (e.g. “user”, “assistant”). Tie-broken on rowid like the other read paths. Used by retry/undo.



163
164
165
166
167
168
169
# File 'lib/rubino/session/store.rb', line 163

def last_for_role(session_id, role)
  row = @db[:messages]
        .where(session_id: session_id, role: role)
        .order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
        .first
  row && hydrate(row)
end

#recent(session_id, count:) ⇒ Object

Returns the N most recent messages for a session



73
74
75
76
77
78
79
80
81
# File 'lib/rubino/session/store.rb', line 73

def recent(session_id, count:)
  @db[:messages]
    .where(session_id: session_id)
    .order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
    .limit(count)
    .all
    .reverse
    .map { |row| hydrate(row) }
end

#search(query:, since: nil, until_: nil, role: nil, tool: nil, limit: 20) ⇒ Array<Hash>

Full-text search across messages backed by the ‘messages_fts` FTS5 virtual table (see migration 007). Returns hydrated rows with an FTS5 snippet() highlighting the match. Filters compose on top of the FTS MATCH so the index does the heavy lifting and SQL prunes the rest.

Parameters:

  • query (String)

    FTS5 MATCH expression; sanitized via Quoting

  • since (String, nil) (defaults to: nil)

    iso8601 lower bound on created_at

  • until_ (String, nil) (defaults to: nil)

    iso8601 upper bound on created_at

  • role (String, nil) (defaults to: nil)

    restrict to a specific message role

  • tool (String, nil) (defaults to: nil)

    restrict to a specific tool_name

  • limit (Integer) (defaults to: 20)

    cap on rows returned (max 100)

Returns:

  • (Array<Hash>)

    rows: session_id, run_id (nil — not tracked on messages), message_id, role, snippet, created_at



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/rubino/session/store.rb', line 132

def search(query:, since: nil, until_: nil, role: nil, tool: nil, limit: 20)
  return [] if query.nil? || query.to_s.strip.empty?

  limit = limit.to_i.clamp(1, 100)
  match_query = sanitize_fts_query(query)

  dataset = @db[:messages_fts]
            .where(Sequel.lit("messages_fts MATCH ?", match_query))
            .join(:messages, Sequel[:messages][:rowid] => Sequel[:messages_fts][:rowid])
            .select(
              Sequel[:messages][:id].as(:message_id),
              Sequel[:messages][:session_id],
              Sequel[:messages][:role],
              Sequel[:messages][:created_at],
              Sequel.lit("snippet(messages_fts, 0, '<mark>', '</mark>', '...', 16) AS snippet")
            )

  dataset = dataset.where(Sequel[:messages][:role] => role) if role
  dataset = dataset.where(Sequel[:messages][:tool_name] => tool) if tool
  dataset = dataset.where(Sequel.lit("messages.created_at >= ?", since)) if since
  dataset = dataset.where(Sequel.lit("messages.created_at <= ?", until_)) if until_

  dataset
    .order(Sequel.desc(Sequel[:messages][:created_at]), Sequel.desc(Sequel.lit("messages.rowid")))
    .limit(limit)
    .all
    .map { |row| row.merge(run_id: nil) }
end

#token_sum(session_id) ⇒ Object

Returns estimated token sum for a session



89
90
91
92
93
# File 'lib/rubino/session/store.rb', line 89

def token_sum(session_id)
  @db[:messages]
    .where(session_id: session_id)
    .sum(:token_count) || 0
end