Class: PromptObjects::Session::Store
- Inherits:
-
Object
- Object
- PromptObjects::Session::Store
- 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
-
#migration_backup_path ⇒ Object
readonly
Returns the value of attribute migration_backup_path.
Instance Method Summary collapse
-
#add_event(entry, session_id: nil) ⇒ Integer
Add an event from the message bus.
-
#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.
-
#auto_name_thread(session_id, first_message, max_length: 40) ⇒ Object
Auto-generate a name for a thread from its first message.
-
#clear_messages(session_id) ⇒ Object
Clear all messages from a session (but keep the session).
-
#close ⇒ Object
Close the database connection.
-
#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.
-
#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).
-
#delete_env_data(root_thread_id:, key:) ⇒ Boolean
Delete an env data entry.
-
#delete_session(id) ⇒ Object
Delete a session, its messages, and any env_data scoped to it.
-
#export_all_sessions(po_name:, format: :json) ⇒ String
Export all sessions for a PO.
-
#export_session_json(session_id) ⇒ Hash
Export a session to JSON format.
-
#export_session_markdown(session_id) ⇒ String
Export a session to Markdown format.
-
#export_thread_tree_json(session_id) ⇒ Hash?
Export a full thread tree as structured JSON.
-
#export_thread_tree_markdown(session_id) ⇒ String?
Export a full thread tree as a single markdown document.
-
#get_child_threads(session_id) ⇒ Array<Hash>
Get all child threads of a session.
-
#get_env_data(root_thread_id:, key:) ⇒ Hash?
Get a single env data entry by key.
-
#get_events(session_id:) ⇒ Array<Hash>
Get events for a session.
-
#get_events_between(start_time, end_time) ⇒ Array<Hash>
Get events between two timestamps.
-
#get_events_since(timestamp, limit: 500) ⇒ Array<Hash>
Get events since a timestamp.
-
#get_latest_session(po_name:) ⇒ Hash?
Get the most recent session for a PO.
-
#get_match_snippet(session_id, query) ⇒ String?
Get a snippet of matching content from a session.
-
#get_messages(session_id) ⇒ Array<Hash>
Get all messages for a session.
-
#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.
-
#get_recent_events(count = 50) ⇒ Array<Hash>
Get recent events.
-
#get_root_threads(po_name:) ⇒ Array<Hash>
Get root threads for a PO (threads with no parent).
-
#get_session(id) ⇒ Hash?
Get a session by ID.
-
#get_thread_lineage(session_id) ⇒ Array<Hash>
Get the lineage (path from root) for a thread.
-
#get_thread_tree(session_id, max_depth: 10) ⇒ Hash
Get the full thread tree starting from a session.
-
#import_session(data, po_name: nil) ⇒ String
Import a session from JSON data.
-
#initialize(db_path) ⇒ Store
constructor
A new instance of Store.
-
#list_all_sessions(source: nil, limit: nil) ⇒ Array<Hash>
List all sessions across all POs.
-
#list_env_data(root_thread_id:) ⇒ Array<Hash>
List all env data keys and descriptions for a root thread (no values).
-
#list_env_data_full(root_thread_id:) ⇒ Array<Hash>
List all env data entries with full values for a root thread.
-
#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.
-
#list_sessions(po_name:) ⇒ Array<Hash>
List all sessions for a PO.
-
#message_count(session_id) ⇒ Integer
Get message count for a session.
-
#resolve_root_thread(session_id) ⇒ String
Resolve the root thread ID for a session by walking up the delegation chain.
-
#search_events(query, limit: 100) ⇒ Array<Hash>
Search events by message content.
-
#search_sessions(query, po_name: nil, source: nil, limit: 50) ⇒ Array<Hash>
Search sessions by message content using full-text search.
-
#search_sessions_fallback(query, po_name: nil, source: nil, limit: 50) ⇒ Array<Hash>
Fallback search without FTS (slower but works on older databases).
-
#session_usage(session_id) ⇒ Hash
Get total token usage for a session.
-
#store_env_data(root_thread_id:, key:, short_description:, value:, stored_by:) ⇒ Object
Store a key-value pair scoped to a root thread.
-
#thread_tree_usage(session_id) ⇒ Hash
Get usage for a full thread tree (session + all descendants).
-
#total_events ⇒ Integer
Get total event count.
-
#total_messages ⇒ Integer
Get total message count across all sessions.
-
#total_sessions ⇒ Integer
Get total session count.
- #transaction ⇒ Object
-
#update_env_data(root_thread_id:, key:, short_description: nil, value: nil, stored_by:) ⇒ Boolean
Update an existing env data entry.
-
#update_session(id, name: nil, metadata: nil, last_message_source: nil) ⇒ Object
Update a session's metadata.
-
#with_connection ⇒ Object
Repositories use this serialized connection boundary during the migration away from direct Store SQL.
Constructor Details
#initialize(db_path) ⇒ Store
Returns a new instance of Store.
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_path ⇒ Object (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.
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) = 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], , 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.
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 (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.
288 289 290 291 292 293 294 |
# File 'lib/prompt_objects/session/store.rb', line 288 def auto_name_thread(session_id, , max_length: 40) return unless auto_name = .to_s.gsub(/\s+/, " ").strip[0, max_length] auto_name += "..." if .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).
527 528 529 530 |
# File 'lib/prompt_objects/session/store.rb', line 527 def (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 |
#close ⇒ Object
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.
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_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).
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_po: parent_po, thread_type: thread_type, **opts ) end |
#delete_env_data(root_thread_id:, key:) ⇒ Boolean
Delete an env data entry.
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.)
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.
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.
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 = (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: .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.
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 = (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**: #{.length}" lines << "" lines << "---" lines << "" .each do |m| = 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} (#{}):" 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.
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.
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.
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.
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.
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.
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.
593 594 595 596 597 598 599 |
# File 'lib/prompt_objects/session/store.rb', line 593 def get_events_since(, limit: 500) rows = @db.execute(<<~SQL, [, 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.
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
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.
507 508 509 510 511 512 513 514 515 |
# File 'lib/prompt_objects/session/store.rb', line 507 def (session_id) rows = @db.execute(<<~SQL, [session_id]) SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC SQL rows.map { |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.
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.
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).
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.
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.
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.
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] = (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.
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 = data[:messages] || [] .each do |m| m = m.transform_keys(&:to_sym) if m.is_a?(Hash) ( 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.
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).
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.
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.
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.
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.
520 521 522 523 |
# File 'lib/prompt_objects/session/store.rb', line 520 def (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.
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.
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.
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..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)
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.
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.
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).
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_events ⇒ Integer
Get total event count.
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_messages ⇒ Integer
Get total message count across all sessions.
536 537 538 539 |
# File 'lib/prompt_objects/session/store.rb', line 536 def row = @db.get_first_row("SELECT COUNT(*) as count FROM messages") row["count"] end |
#total_sessions ⇒ Integer
Get total session count.
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 |
#transaction ⇒ Object
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.
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.
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 updates << "last_message_source = ?" params << 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_connection ⇒ Object
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 |