Class: PromptObjects::Session::Store

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

Overview

SQLite-based session storage for conversation history. Each environment has its own sessions.db file (gitignored for privacy).

Constant Summary collapse

SCHEMA_VERSION =
11
THREAD_TYPES =

Thread types for conversation branching

%w[root continuation delegation fork].freeze
SOURCES =

Valid source values for session tracking

%w[tui mcp api web cli].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(db_path) ⇒ Store

Returns a new instance of Store.

Parameters:

  • db_path (String)

    Path to the SQLite database file



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/prompt_objects/session/store.rb', line 24

def initialize(db_path)
  @db_path = db_path
  @db_monitor = Monitor.new
  @db = SQLite3::Database.new(db_path)
  @db.results_as_hash = true

  # Enable WAL mode for better concurrent access (TUI + MCP can access simultaneously)
  @db.execute("PRAGMA journal_mode=WAL")

  # Set busy timeout to 5 seconds - wait for locks instead of failing immediately
  @db.busy_timeout = 5000

  backup_before_destructive_migration
  setup_schema
  @db.execute("PRAGMA foreign_keys=ON")
end

Instance Attribute Details

#migration_backup_pathObject (readonly)

Returns the value of attribute migration_backup_path.



15
16
17
# File 'lib/prompt_objects/session/store.rb', line 15

def migration_backup_path
  @migration_backup_path
end

Instance Method Details

#add_event(entry, session_id: nil) ⇒ Integer

Add an event from the message bus.

Parameters:

  • entry (Hash)

    Bus entry with :timestamp, :from, :to, :message, :summary

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

    Associated session ID

Returns:

  • (Integer)

    Event ID



554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/prompt_objects/session/store.rb', line 554

def add_event(entry, session_id: nil)
  message_text = case entry[:message]
                 when Hash then entry[:message].to_json
                 when String then entry[:message]
                 else entry[:message].to_s
                 end

  params = [
    session_id || entry[:session_id],
    entry[:timestamp].iso8601,
    entry[:from],
    entry[:to],
    message_text,
    entry[:summary]
  ]

  @db.execute(<<~SQL, params)
    INSERT INTO events (session_id, timestamp, from_name, to_name, message, summary)
    VALUES (?, ?, ?, ?, ?, ?)
  SQL

  @db.last_insert_row_id
end

#add_message(session_id:, role:, content: nil, from_po: nil, tool_calls: nil, tool_results: nil, usage: nil, source: nil) ⇒ Integer

Add a message to a session.

Parameters:

  • session_id (String)

    Session ID

  • role (Symbol, String)

    Message role (:user, :assistant, :tool)

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

    Message content

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

    Source PO for delegation tracking

  • tool_calls (Array, nil) (defaults to: nil)

    Tool calls data

  • tool_results (Array, nil) (defaults to: nil)

    Tool results data

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

    Source interface that added this message

Returns:

  • (Integer)

    Message ID



475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'lib/prompt_objects/session/store.rb', line 475

def add_message(session_id:, role:, content: nil, from_po: nil, tool_calls: nil, tool_results: nil, usage: nil, source: nil)
  now = Time.now.utc.iso8601

  params = [
    session_id,
    role.to_s,
    content,
    from_po,
    tool_calls&.to_json,
    tool_results&.to_json,
    usage&.to_json,
    now
  ]

  @db.execute(<<~SQL, params)
    INSERT INTO messages (session_id, role, content, from_po, tool_calls, tool_results, usage, created_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  SQL

  # Update session's updated_at and optionally last_message_source
  if source
    @db.execute("UPDATE sessions SET updated_at = ?, last_message_source = ? WHERE id = ?", [now, source, session_id])
  else
    @db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, session_id])
  end

  @db.last_insert_row_id
end

#auto_name_thread(session_id, first_message, max_length: 40) ⇒ Object

Auto-generate a name for a thread from its first message.

Parameters:

  • session_id (String)

    Session ID

  • first_message (String)

    First message content

  • max_length (Integer) (defaults to: 40)

    Maximum name length



288
289
290
291
292
293
294
# File 'lib/prompt_objects/session/store.rb', line 288

def auto_name_thread(session_id, first_message, max_length: 40)
  return unless first_message

  auto_name = first_message.to_s.gsub(/\s+/, " ").strip[0, max_length]
  auto_name += "..." if first_message.to_s.length > max_length
  update_session(session_id, name: auto_name)
end

#clear_messages(session_id) ⇒ Object

Clear all messages from a session (but keep the session).

Parameters:

  • session_id (String)

    Session ID



527
528
529
530
# File 'lib/prompt_objects/session/store.rb', line 527

def clear_messages(session_id)
  @db.execute("DELETE FROM messages WHERE session_id = ?", [session_id])
  @db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", [Time.now.utc.iso8601, session_id])
end

#closeObject

Close the database connection.



42
43
44
# File 'lib/prompt_objects/session/store.rb', line 42

def close
  @db_monitor.synchronize { @db.close if @db }
end

#create_session(po_name:, name: nil, source: "tui", source_client: nil, metadata: {}, parent_session_id: nil, parent_message_id: nil, parent_po: nil, thread_type: "root", workspace_session_id: nil, id: SecureRandom.uuid) ⇒ String

Create a new session (thread) for a PO.

Parameters:

  • po_name (String)

    Name of the prompt object

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

    Optional session name

  • source (String) (defaults to: "tui")

    Source interface (tui, mcp, api, web, cli)

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

    Client identifier (e.g., "claude-desktop", "cursor")

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

    Optional metadata

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

    Parent thread ID (for branching)

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

    Message ID that spawned this thread

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

    PO that created this thread (for cross-PO delegation)

  • thread_type (String) (defaults to: "root")

    Type of thread: root, continuation, delegation, fork

Returns:

  • (String)

    Session ID



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

def create_session(po_name:, name: nil, source: "tui", source_client: nil, metadata: {},
                   parent_session_id: nil, parent_message_id: nil, parent_po: nil,
                   thread_type: "root", workspace_session_id: nil, id: SecureRandom.uuid)
  now = Time.now.utc.iso8601

  @db.execute(<<~SQL, [id, po_name, name, source, source_client, source, now, now, .to_json, parent_session_id, parent_message_id, parent_po, thread_type, workspace_session_id])
    INSERT INTO sessions (id, po_name, name, source, source_client, last_message_source, created_at, updated_at, metadata, parent_session_id, parent_message_id, parent_po, thread_type, workspace_session_id)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  SQL

  id
end

#create_thread(po_name:, parent_session_id: nil, parent_message_id: nil, parent_po: nil, thread_type: "root", **opts) ⇒ String

Create a new thread (alias for create_session with thread semantics).

Parameters:

  • po_name (String)

    Name of the prompt object

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

    Parent thread ID

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

    Message ID that spawned this thread

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

    PO that created this thread

  • thread_type (String) (defaults to: "root")

    Type: root, continuation, delegation, fork

  • opts (Hash)

    Additional options passed to create_session

Returns:

  • (String)

    Thread/Session ID



93
94
95
96
97
98
99
100
101
102
# File 'lib/prompt_objects/session/store.rb', line 93

def create_thread(po_name:, parent_session_id: nil, parent_message_id: nil, parent_po: nil, thread_type: "root", **opts)
  create_session(
    po_name: po_name,
    parent_session_id: parent_session_id,
    parent_message_id: parent_message_id,
    parent_po: parent_po,
    thread_type: thread_type,
    **opts
  )
end

#delete_env_data(root_thread_id:, key:) ⇒ Boolean

Delete an env data entry.

Parameters:

  • root_thread_id (String)

    Root thread scope

  • key (String)

    Data key

Returns:

  • (Boolean)

    True if deleted, false if key not found



769
770
771
772
# File 'lib/prompt_objects/session/store.rb', line 769

def delete_env_data(root_thread_id:, key:)
  @db.execute("DELETE FROM env_data WHERE root_thread_id = ? AND key = ?", [root_thread_id, key])
  @db.changes > 0
end

#delete_session(id) ⇒ Object

Delete a session, its messages, and any env_data scoped to it. (env_data is keyed by root_thread_id; for a non-root sub-thread this DELETE is a harmless no-op since its data lives under the root.)

Parameters:

  • id (String)

    Session ID



458
459
460
461
462
# File 'lib/prompt_objects/session/store.rb', line 458

def delete_session(id)
  @db.execute("DELETE FROM messages WHERE session_id = ?", [id])
  @db.execute("DELETE FROM env_data WHERE root_thread_id = ?", [id])
  @db.execute("DELETE FROM sessions WHERE id = ?", [id])
end

#export_all_sessions(po_name:, format: :json) ⇒ String

Export all sessions for a PO.

Parameters:

  • po_name (String)

    PO name

  • format (Symbol) (defaults to: :json)

    :json or :markdown

Returns:

  • (String)

    Exported content



899
900
901
902
903
904
905
906
907
908
909
910
911
# File 'lib/prompt_objects/session/store.rb', line 899

def export_all_sessions(po_name:, format: :json)
  sessions = list_sessions(po_name: po_name)

  case format
  when :json
    exported = sessions.map { |s| export_session_json(s[:id]) }
    JSON.pretty_generate(exported)
  when :markdown
    sessions.map { |s| export_session_markdown(s[:id]) }.join("\n\n")
  else
    raise ArgumentError, "Unknown format: #{format}"
  end
end

#export_session_json(session_id) ⇒ Hash

Export a session to JSON format.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Hash)

    Session data with messages



802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
# File 'lib/prompt_objects/session/store.rb', line 802

def export_session_json(session_id)
  session = get_session(session_id)
  return nil unless session

  messages = get_messages(session_id)

  {
    id: session[:id],
    po_name: session[:po_name],
    name: session[:name],
    source: session[:source],
    source_client: session[:source_client],
    created_at: session[:created_at]&.iso8601,
    updated_at: session[:updated_at]&.iso8601,
    metadata: session[:metadata],
    messages: messages.map do |m|
      {
        role: m[:role].to_s,
        content: m[:content],
        from_po: m[:from_po],
        tool_calls: m[:tool_calls],
        tool_results: m[:tool_results],
        created_at: m[:created_at]&.iso8601
      }
    end
  }
end

#export_session_markdown(session_id) ⇒ String

Export a session to Markdown format.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (String)

    Markdown content



833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
# File 'lib/prompt_objects/session/store.rb', line 833

def export_session_markdown(session_id)
  session = get_session(session_id)
  return nil unless session

  messages = get_messages(session_id)

  lines = []
  lines << "# Session: #{session[:name] || 'Unnamed'}"
  lines << ""
  lines << "- **PO**: #{session[:po_name]}"
  lines << "- **Source**: #{session[:source]}"
  lines << "- **Created**: #{session[:created_at]&.strftime('%Y-%m-%d %H:%M')}"
  lines << "- **Updated**: #{session[:updated_at]&.strftime('%Y-%m-%d %H:%M')}"
  lines << "- **Messages**: #{messages.length}"
  lines << ""
  lines << "---"
  lines << ""

  messages.each do |m|
    timestamp = m[:created_at]&.strftime('%H:%M')
    role_label = case m[:role].to_s
                 when "user" then "**User**"
                 when "assistant" then "**#{m[:from_po] || session[:po_name]}**"
                 when "tool" then "*Tool*"
                 else "**#{m[:role]}**"
                 end

    lines << "#{role_label} (#{timestamp}):"
    lines << ""

    if m[:content]
      lines << m[:content]
      lines << ""
    end

    if m[:tool_calls]
      lines << "<details><summary>Tool calls</summary>"
      lines << ""
      lines << "```json"
      lines << JSON.pretty_generate(m[:tool_calls])
      lines << "```"
      lines << "</details>"
      lines << ""
    end

    if m[:tool_results]
      lines << "<details><summary>Tool results</summary>"
      lines << ""
      lines << "```json"
      lines << JSON.pretty_generate(m[:tool_results])
      lines << "```"
      lines << "</details>"
      lines << ""
    end

    lines << "---"
    lines << ""
  end

  lines.join("\n")
end

#export_thread_tree_json(session_id) ⇒ Hash?

Export a full thread tree as structured JSON.

Parameters:

  • session_id (String)

    Root session ID

Returns:

  • (Hash, nil)

    Tree data



938
939
940
941
942
943
# File 'lib/prompt_objects/session/store.rb', line 938

def export_thread_tree_json(session_id)
  tree = get_thread_tree(session_id)
  return nil unless tree

  serialize_tree_for_export(tree, is_root: true)
end

#export_thread_tree_markdown(session_id) ⇒ String?

Export a full thread tree as a single markdown document. Follows all delegation sub-threads recursively.

Parameters:

  • session_id (String)

    Root session ID

Returns:

  • (String, nil)

    Markdown content



917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
# File 'lib/prompt_objects/session/store.rb', line 917

def export_thread_tree_markdown(session_id)
  tree = get_thread_tree(session_id)
  return nil unless tree

  lines = []
  lines << "# Thread Export"
  lines << ""
  lines << "- **Root PO**: #{tree[:session][:po_name]}"
  lines << "- **Started**: #{tree[:session][:created_at]&.strftime('%Y-%m-%d %H:%M')}"
  lines << "- **Exported**: #{Time.now.strftime('%Y-%m-%d %H:%M')}"
  lines << ""
  lines << "---"
  lines << ""

  render_thread_node(tree, lines, depth: 0)
  lines.join("\n")
end

#get_child_threads(session_id) ⇒ Array<Hash>

Get all child threads of a session.

Parameters:

  • session_id (String)

    Parent session ID

Returns:

  • (Array<Hash>)

    Child session data with message counts



215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/prompt_objects/session/store.rb', line 215

def get_child_threads(session_id)
  rows = @db.execute(<<~SQL, [session_id])
    SELECT s.*, COUNT(m.id) as message_count
    FROM sessions s
    LEFT JOIN messages m ON m.session_id = s.id
    WHERE s.parent_session_id = ?
    GROUP BY s.id
    ORDER BY s.created_at ASC
  SQL

  rows.map { |row| parse_session_row(row, include_count: true) }
end

#get_env_data(root_thread_id:, key:) ⇒ Hash?

Get a single env data entry by key.

Parameters:

  • root_thread_id (String)

    Root thread scope

  • key (String)

    Data key

Returns:

  • (Hash, nil)

    Entry with parsed value, or nil if not found



681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
# File 'lib/prompt_objects/session/store.rb', line 681

def get_env_data(root_thread_id:, key:)
  row = @db.get_first_row(<<~SQL, [root_thread_id, key])
    SELECT key, short_description, value, stored_by, created_at, updated_at
    FROM env_data
    WHERE root_thread_id = ? AND key = ?
  SQL

  return nil unless row

  {
    key: row["key"],
    short_description: row["short_description"],
    value: JSON.parse(row["value"], symbolize_names: true),
    stored_by: row["stored_by"],
    created_at: row["created_at"],
    updated_at: row["updated_at"]
  }
end

#get_events(session_id:) ⇒ Array<Hash>

Get events for a session.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Array<Hash>)


581
582
583
584
585
586
587
# File 'lib/prompt_objects/session/store.rb', line 581

def get_events(session_id:)
  rows = @db.execute(<<~SQL, [session_id])
    SELECT * FROM events WHERE session_id = ? ORDER BY id ASC
  SQL

  rows.map { |row| parse_event_row(row) }
end

#get_events_between(start_time, end_time) ⇒ Array<Hash>

Get events between two timestamps.

Parameters:

  • start_time (String)

    ISO8601 start timestamp

  • end_time (String)

    ISO8601 end timestamp

Returns:

  • (Array<Hash>)


605
606
607
608
609
610
611
# File 'lib/prompt_objects/session/store.rb', line 605

def get_events_between(start_time, end_time)
  rows = @db.execute(<<~SQL, [start_time, end_time])
    SELECT * FROM events WHERE timestamp BETWEEN ? AND ? ORDER BY id ASC
  SQL

  rows.map { |row| parse_event_row(row) }
end

#get_events_since(timestamp, limit: 500) ⇒ Array<Hash>

Get events since a timestamp.

Parameters:

  • timestamp (String)

    ISO8601 timestamp

  • limit (Integer) (defaults to: 500)

    Maximum events to return

Returns:

  • (Array<Hash>)


593
594
595
596
597
598
599
# File 'lib/prompt_objects/session/store.rb', line 593

def get_events_since(timestamp, limit: 500)
  rows = @db.execute(<<~SQL, [timestamp, limit])
    SELECT * FROM events WHERE timestamp > ? ORDER BY id ASC LIMIT ?
  SQL

  rows.map { |row| parse_event_row(row) }
end

#get_latest_session(po_name:) ⇒ Hash?

Get the most recent session for a PO.

Parameters:

  • po_name (String)

    Name of the prompt object

Returns:

  • (Hash, nil)

    Session data or nil



130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/prompt_objects/session/store.rb', line 130

def get_latest_session(po_name:)
  row = @db.get_first_row(<<~SQL, [po_name])
    SELECT * FROM sessions
    WHERE po_name = ?
    ORDER BY updated_at DESC
    LIMIT 1
  SQL

  return nil unless row

  parse_session_row(row)
end

#get_match_snippet(session_id, query) ⇒ String?

Get a snippet of matching content from a session

Parameters:

  • session_id (String)

    Session ID

  • query (String)

    Search query

Returns:

  • (String, nil)

    Snippet with match highlighted



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/prompt_objects/session/store.rb', line 357

def get_match_snippet(session_id, query)
  # Get first message that matches
  row = @db.get_first_row(<<~SQL, [session_id, "%#{query}%"])
    SELECT content FROM messages
    WHERE session_id = ? AND content LIKE ?
    LIMIT 1
  SQL

  return nil unless row && row["content"]

  content = row["content"]
  # Find match position and extract snippet
  query_lower = query.downcase
  pos = content.downcase.index(query_lower)
  return content[0, 60] + "..." unless pos

  # Extract snippet around match
  start_pos = [pos - 20, 0].max
  end_pos = [pos + query.length + 40, content.length].min
  snippet = content[start_pos, end_pos - start_pos]

  # Add ellipsis if truncated
  snippet = "..." + snippet if start_pos > 0
  snippet = snippet + "..." if end_pos < content.length

  # Highlight match with markers
  snippet.gsub(/#{Regexp.escape(query)}/i) { |m| ">>>#{m}<<<" }
end

#get_messages(session_id) ⇒ Array<Hash>

Get all messages for a session.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Array<Hash>)

    Messages in chronological order



507
508
509
510
511
512
513
514
515
# File 'lib/prompt_objects/session/store.rb', line 507

def get_messages(session_id)
  rows = @db.execute(<<~SQL, [session_id])
    SELECT * FROM messages
    WHERE session_id = ?
    ORDER BY id ASC
  SQL

  rows.map { |row| parse_message_row(row) }
end

#get_or_create_session(po_name:, source: "tui", source_client: nil) ⇒ Hash

Get the most recent session for a PO, or create one if none exists.

Parameters:

  • po_name (String)

    Name of the prompt object

  • source (String) (defaults to: "tui")

    Source interface for new session

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

    Client identifier for new session

Returns:

  • (Hash)

    Session data



119
120
121
122
123
124
125
# File 'lib/prompt_objects/session/store.rb', line 119

def get_or_create_session(po_name:, source: "tui", source_client: nil)
  session = get_latest_session(po_name: po_name)
  return session if session

  id = create_session(po_name: po_name, source: source, source_client: source_client)
  get_session(id)
end

#get_recent_events(count = 50) ⇒ Array<Hash>

Get recent events.

Parameters:

  • count (Integer) (defaults to: 50)

    Number of events

Returns:

  • (Array<Hash>)


616
617
618
619
620
621
622
# File 'lib/prompt_objects/session/store.rb', line 616

def get_recent_events(count = 50)
  rows = @db.execute(<<~SQL, [count])
    SELECT * FROM events ORDER BY id DESC LIMIT ?
  SQL

  rows.map { |row| parse_event_row(row) }.reverse
end

#get_root_threads(po_name:) ⇒ Array<Hash>

Get root threads for a PO (threads with no parent).

Parameters:

  • po_name (String)

    Name of the prompt object

Returns:

  • (Array<Hash>)

    Root session data with message counts



271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/prompt_objects/session/store.rb', line 271

def get_root_threads(po_name:)
  rows = @db.execute(<<~SQL, [po_name])
    SELECT s.*, COUNT(m.id) as message_count
    FROM sessions s
    LEFT JOIN messages m ON m.session_id = s.id
    WHERE s.po_name = ? AND s.parent_session_id IS NULL
    GROUP BY s.id
    ORDER BY s.updated_at DESC
  SQL

  rows.map { |row| parse_session_row(row, include_count: true) }
end

#get_session(id) ⇒ Hash?

Get a session by ID.

Parameters:

  • id (String)

    Session ID

Returns:

  • (Hash, nil)

    Session data or nil if not found



107
108
109
110
111
112
# File 'lib/prompt_objects/session/store.rb', line 107

def get_session(id)
  row = @db.get_first_row("SELECT * FROM sessions WHERE id = ?", [id])
  return nil unless row

  parse_session_row(row)
end

#get_thread_lineage(session_id) ⇒ Array<Hash>

Get the lineage (path from root) for a thread.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Array<Hash>)

    Ancestors from root to current (inclusive)



231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/prompt_objects/session/store.rb', line 231

def get_thread_lineage(session_id)
  lineage = []
  current_id = session_id

  while current_id
    session = get_session(current_id)
    break unless session

    lineage.unshift(session)
    current_id = session[:parent_session_id]
  end

  lineage
end

#get_thread_tree(session_id, max_depth: 10) ⇒ Hash

Get the full thread tree starting from a session.

Parameters:

  • session_id (String)

    Root session ID

  • max_depth (Integer) (defaults to: 10)

    Maximum recursion depth

Returns:

  • (Hash)

    Tree structure with session and children



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/prompt_objects/session/store.rb', line 250

def get_thread_tree(session_id, max_depth: 10)
  return nil if max_depth <= 0

  session = get_session(session_id)
  return nil unless session

  # Add message count
  session[:message_count] = message_count(session_id)

  children = get_child_threads(session_id)
  child_trees = children.map { |child| get_thread_tree(child[:id], max_depth: max_depth - 1) }.compact

  {
    session: session,
    children: child_trees
  }
end

#import_session(data, po_name: nil) ⇒ String

Import a session from JSON data.

Parameters:

  • data (Hash)

    Session data (as returned by export_session_json)

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

    Override PO name

Returns:

  • (String)

    New session ID



951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
# File 'lib/prompt_objects/session/store.rb', line 951

def import_session(data, po_name: nil)
  data = data.transform_keys(&:to_sym) if data.is_a?(Hash)

  # Create new session with new ID
  new_id = create_session(
    po_name: po_name || data[:po_name],
    name: "#{data[:name]} (imported)",
    source: "tui",
    metadata: (data[:metadata] || {}).merge(
      imported_from: data[:id],
      imported_at: Time.now.utc.iso8601,
      original_source: data[:source]
    )
  )

  # Import messages
  messages = data[:messages] || []
  messages.each do |m|
    m = m.transform_keys(&:to_sym) if m.is_a?(Hash)
    add_message(
      session_id: new_id,
      role: m[:role],
      content: m[:content],
      from_po: m[:from_po],
      tool_calls: m[:tool_calls],
      tool_results: m[:tool_results]
    )
  end

  new_id
end

#list_all_sessions(source: nil, limit: nil) ⇒ Array<Hash>

List all sessions across all POs.

Parameters:

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

    Filter by source interface

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

    Maximum number of sessions

Returns:

  • (Array<Hash>)

    Session data with message counts



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/prompt_objects/session/store.rb', line 163

def list_all_sessions(source: nil, limit: nil)
  sql = <<~SQL
    SELECT s.*, COUNT(m.id) as message_count
    FROM sessions s
    LEFT JOIN messages m ON m.session_id = s.id
  SQL

  params = []
  if source
    sql += " WHERE s.source = ?"
    params << source
  end

  sql += " GROUP BY s.id ORDER BY s.updated_at DESC"

  if limit
    sql += " LIMIT ?"
    params << limit
  end

  rows = @db.execute(sql, params)
  rows.map { |row| parse_session_row(row, include_count: true) }
end

#list_env_data(root_thread_id:) ⇒ Array<Hash>

List all env data keys and descriptions for a root thread (no values).

Parameters:

  • root_thread_id (String)

    Root thread scope

Returns:

  • (Array<Hash>)

    Entries with key and short_description only



703
704
705
706
707
708
709
710
711
# File 'lib/prompt_objects/session/store.rb', line 703

def list_env_data(root_thread_id:)
  rows = @db.execute(<<~SQL, [root_thread_id])
    SELECT key, short_description FROM env_data
    WHERE root_thread_id = ?
    ORDER BY key ASC
  SQL

  rows.map { |row| { key: row["key"], short_description: row["short_description"] } }
end

#list_env_data_full(root_thread_id:) ⇒ Array<Hash>

List all env data entries with full values for a root thread.

Parameters:

  • root_thread_id (String)

    Root thread scope

Returns:

  • (Array<Hash>)

    Full entries with parsed values



716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
# File 'lib/prompt_objects/session/store.rb', line 716

def list_env_data_full(root_thread_id:)
  rows = @db.execute(<<~SQL, [root_thread_id])
    SELECT key, short_description, value, stored_by, created_at, updated_at
    FROM env_data WHERE root_thread_id = ? ORDER BY key ASC
  SQL

  rows.map do |row|
    {
      key: row["key"],
      short_description: row["short_description"],
      value: JSON.parse(row["value"]),
      stored_by: row["stored_by"],
      created_at: row["created_at"],
      updated_at: row["updated_at"]
    }
  end
end

#list_environment_sessions(limit: nil) ⇒ Array<Hash>

List environment-level sessions (po_name IS NULL) — the shared-context "jam" sessions managed from the UI, most recent first.

Parameters:

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

    Maximum number of sessions

Returns:

  • (Array<Hash>)

    Session data with message counts



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/prompt_objects/session/store.rb', line 191

def list_environment_sessions(limit: nil)
  sql = <<~SQL
    SELECT s.*, COUNT(m.id) as message_count
    FROM sessions s
    LEFT JOIN messages m ON m.session_id = s.id
    WHERE s.po_name IS NULL
    GROUP BY s.id
    ORDER BY s.updated_at DESC
  SQL

  params = []
  if limit
    sql += " LIMIT ?"
    params << limit
  end

  @db.execute(sql, params).map { |row| parse_session_row(row, include_count: true) }
end

#list_sessions(po_name:) ⇒ Array<Hash>

List all sessions for a PO.

Parameters:

  • po_name (String)

    Name of the prompt object

Returns:

  • (Array<Hash>)

    Session data with message counts



146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/prompt_objects/session/store.rb', line 146

def list_sessions(po_name:)
  rows = @db.execute(<<~SQL, [po_name])
    SELECT s.*, COUNT(m.id) as message_count
    FROM sessions s
    LEFT JOIN messages m ON m.session_id = s.id
    WHERE s.po_name = ?
    GROUP BY s.id
    ORDER BY s.updated_at DESC
  SQL

  rows.map { |row| parse_session_row(row, include_count: true) }
end

#message_count(session_id) ⇒ Integer

Get message count for a session.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Integer)


520
521
522
523
# File 'lib/prompt_objects/session/store.rb', line 520

def message_count(session_id)
  row = @db.get_first_row("SELECT COUNT(*) as count FROM messages WHERE session_id = ?", [session_id])
  row["count"]
end

#resolve_root_thread(session_id) ⇒ String

Resolve the root thread ID for a session by walking up the delegation chain.

Parameters:

  • session_id (String)

    Any session ID in a delegation chain

Returns:

  • (String)

    The root thread's session ID



648
649
650
651
652
653
# File 'lib/prompt_objects/session/store.rb', line 648

def resolve_root_thread(session_id)
  lineage = get_thread_lineage(session_id)
  return session_id if lineage.empty?

  lineage.first[:id]
end

#search_events(query, limit: 100) ⇒ Array<Hash>

Search events by message content.

Parameters:

  • query (String)

    Search text

  • limit (Integer) (defaults to: 100)

    Maximum results

Returns:

  • (Array<Hash>)


628
629
630
631
632
633
634
# File 'lib/prompt_objects/session/store.rb', line 628

def search_events(query, limit: 100)
  rows = @db.execute(<<~SQL, ["%#{query}%", limit])
    SELECT * FROM events WHERE message LIKE ? ORDER BY id DESC LIMIT ?
  SQL

  rows.map { |row| parse_event_row(row) }
end

#search_sessions(query, po_name: nil, source: nil, limit: 50) ⇒ Array<Hash>

Search sessions by message content using full-text search.

Parameters:

  • query (String)

    Search query

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

    Filter by PO

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

    Filter by source

  • limit (Integer) (defaults to: 50)

    Maximum results

Returns:

  • (Array<Hash>)

    Sessions with match info



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/prompt_objects/session/store.rb', line 302

def search_sessions(query, po_name: nil, source: nil, limit: 50)
  return [] if query.nil? || query.strip.empty?

  # Use FTS5 MATCH syntax
  # Escape special characters and add prefix matching
  safe_query = query.gsub(/['"()]/, " ").strip
  fts_query = safe_query.split.map { |term| "#{term}*" }.join(" ")

  # First get matching message IDs from FTS
  sql = <<~SQL
    SELECT DISTINCT s.*, COUNT(m.id) as message_count
    FROM sessions s
    INNER JOIN messages m ON m.session_id = s.id
    INNER JOIN messages_fts ON messages_fts.rowid = m.id
    WHERE messages_fts MATCH ?
  SQL

  params = [fts_query]

  if po_name
    sql += " AND s.po_name = ?"
    params << po_name
  end

  if source
    sql += " AND s.source = ?"
    params << source
  end

  sql += " GROUP BY s.id ORDER BY s.updated_at DESC LIMIT ?"
  params << limit

  rows = @db.execute(sql, params)
  results = rows.map { |row| parse_session_row(row, include_count: true) }

  # Get a snippet from matching messages for each session
  results.each do |session|
    snippet = get_match_snippet(session[:id], query)
    session[:match_snippet] = snippet if snippet
  end

  results
rescue SQLite3::SQLException => e
  # FTS table might not exist in older databases
  if e.message.include?("no such table")
    search_sessions_fallback(query, po_name: po_name, source: source, limit: limit)
  else
    raise
  end
end

#search_sessions_fallback(query, po_name: nil, source: nil, limit: 50) ⇒ Array<Hash>

Fallback search without FTS (slower but works on older databases)

Parameters:

  • query (String)

    Search query

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

    Filter by PO

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

    Filter by source

  • limit (Integer) (defaults to: 50)

    Maximum results

Returns:

  • (Array<Hash>)

    Sessions with match info



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'lib/prompt_objects/session/store.rb', line 392

def search_sessions_fallback(query, po_name: nil, source: nil, limit: 50)
  return [] if query.nil? || query.strip.empty?

  sql = <<~SQL
    SELECT DISTINCT s.*, COUNT(m.id) as message_count
    FROM sessions s
    INNER JOIN messages m ON m.session_id = s.id
    WHERE m.content LIKE ?
  SQL

  params = ["%#{query}%"]

  if po_name
    sql += " AND s.po_name = ?"
    params << po_name
  end

  if source
    sql += " AND s.source = ?"
    params << source
  end

  sql += " GROUP BY s.id ORDER BY s.updated_at DESC LIMIT ?"
  params << limit

  rows = @db.execute(sql, params)
  rows.map { |row| parse_session_row(row, include_count: true) }
end

#session_usage(session_id) ⇒ Hash

Get total token usage for a session.

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Hash)

    Aggregated usage data



779
780
781
782
783
784
785
# File 'lib/prompt_objects/session/store.rb', line 779

def session_usage(session_id)
  rows = @db.execute(<<~SQL, [session_id])
    SELECT usage FROM messages WHERE session_id = ? AND usage IS NOT NULL
  SQL

  aggregate_usage_rows(rows)
end

#store_env_data(root_thread_id:, key:, short_description:, value:, stored_by:) ⇒ Object

Store a key-value pair scoped to a root thread. Uses INSERT OR REPLACE to create or overwrite.

Parameters:

  • root_thread_id (String)

    Root thread scope

  • key (String)

    Data key

  • short_description (String)

    Brief description for discoverability

  • value (Object)

    Data value (will be JSON-serialized)

  • stored_by (String)

    PO name that stored this



662
663
664
665
666
667
668
669
670
671
672
673
674
675
# File 'lib/prompt_objects/session/store.rb', line 662

def store_env_data(root_thread_id:, key:, short_description:, value:, stored_by:)
  now = Time.now.utc.iso8601
  json_value = JSON.generate(value)

  @db.execute(<<~SQL, [root_thread_id, key, short_description, json_value, stored_by, now, now])
    INSERT INTO env_data (root_thread_id, key, short_description, value, stored_by, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(root_thread_id, key) DO UPDATE SET
      short_description = excluded.short_description,
      value = excluded.value,
      stored_by = excluded.stored_by,
      updated_at = excluded.updated_at
  SQL
end

#thread_tree_usage(session_id) ⇒ Hash

Get usage for a full thread tree (session + all descendants).

Parameters:

  • session_id (String)

    Root session ID

Returns:

  • (Hash)

    Aggregated usage across the tree



790
791
792
793
794
795
# File 'lib/prompt_objects/session/store.rb', line 790

def thread_tree_usage(session_id)
  tree = get_thread_tree(session_id)
  return empty_usage unless tree

  collect_tree_usage(tree)
end

#total_eventsInteger

Get total event count.

Returns:

  • (Integer)


638
639
640
641
# File 'lib/prompt_objects/session/store.rb', line 638

def total_events
  row = @db.get_first_row("SELECT COUNT(*) as count FROM events")
  row["count"]
end

#total_messagesInteger

Get total message count across all sessions.

Returns:

  • (Integer)


536
537
538
539
# File 'lib/prompt_objects/session/store.rb', line 536

def total_messages
  row = @db.get_first_row("SELECT COUNT(*) as count FROM messages")
  row["count"]
end

#total_sessionsInteger

Get total session count.

Returns:

  • (Integer)


543
544
545
546
# File 'lib/prompt_objects/session/store.rb', line 543

def total_sessions
  row = @db.get_first_row("SELECT COUNT(*) as count FROM sessions")
  row["count"]
end

#transactionObject



53
54
55
56
57
# File 'lib/prompt_objects/session/store.rb', line 53

def transaction
  @db_monitor.synchronize do
    @db.transaction { yield @db }
  end
end

#update_env_data(root_thread_id:, key:, short_description: nil, value: nil, stored_by:) ⇒ Boolean

Update an existing env data entry.

Parameters:

  • root_thread_id (String)

    Root thread scope

  • key (String)

    Data key

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

    New description (keeps existing if nil)

  • value (Object, nil) (defaults to: nil)

    New value (keeps existing if nil)

  • stored_by (String)

    PO name performing the update

Returns:

  • (Boolean)

    True if updated, false if key not found



741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
# File 'lib/prompt_objects/session/store.rb', line 741

def update_env_data(root_thread_id:, key:, short_description: nil, value: nil, stored_by:)
  existing = get_env_data(root_thread_id: root_thread_id, key: key)
  return false unless existing

  updates = ["updated_at = ?", "stored_by = ?"]
  params = [Time.now.utc.iso8601, stored_by]

  if short_description
    updates << "short_description = ?"
    params << short_description
  end

  if value
    updates << "value = ?"
    params << JSON.generate(value)
  end

  params << root_thread_id
  params << key

  @db.execute("UPDATE env_data SET #{updates.join(', ')} WHERE root_thread_id = ? AND key = ?", params)
  true
end

#update_session(id, name: nil, metadata: nil, last_message_source: nil) ⇒ Object

Update a session's metadata.

Parameters:

  • id (String)

    Session ID

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

    New session name

  • metadata (Hash, nil) (defaults to: nil)

    New metadata (merged with existing)

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

    Source of last message (tui, mcp, api)



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/prompt_objects/session/store.rb', line 426

def update_session(id, name: nil, metadata: nil, last_message_source: nil)
  updates = ["updated_at = ?"]
  params = [Time.now.utc.iso8601]

  if name
    updates << "name = ?"
    params << name
  end

  if last_message_source
    updates << "last_message_source = ?"
    params << last_message_source
  end

  if 
    # Merge with existing metadata
    existing = get_session(id)
    if existing
      merged = (existing[:metadata] || {}).merge()
      updates << "metadata = ?"
      params << merged.to_json
    end
  end

  params << id
  @db.execute("UPDATE sessions SET #{updates.join(', ')} WHERE id = ?", params)
end

#with_connectionObject

Repositories use this serialized connection boundary during the migration away from direct Store SQL. Monitor is reentrant so a repository transaction may safely call a repository read helper.



49
50
51
# File 'lib/prompt_objects/session/store.rb', line 49

def with_connection
  @db_monitor.synchronize { yield @db }
end