prompt_objects 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLAUDE.md +108 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +231 -0
- data/IMPLEMENTATION_PLAN.md +1073 -0
- data/LICENSE +21 -0
- data/README.md +73 -0
- data/Rakefile +27 -0
- data/design-doc-v2.md +1232 -0
- data/exe/prompt_objects +572 -0
- data/exe/prompt_objects_mcp +34 -0
- data/frontend/.gitignore +3 -0
- data/frontend/index.html +13 -0
- data/frontend/package-lock.json +4417 -0
- data/frontend/package.json +32 -0
- data/frontend/postcss.config.js +6 -0
- data/frontend/src/App.tsx +95 -0
- data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
- data/frontend/src/components/ChatPanel.tsx +251 -0
- data/frontend/src/components/Dashboard.tsx +83 -0
- data/frontend/src/components/Header.tsx +141 -0
- data/frontend/src/components/MarkdownMessage.tsx +153 -0
- data/frontend/src/components/MessageBus.tsx +55 -0
- data/frontend/src/components/ModelSelector.tsx +112 -0
- data/frontend/src/components/NotificationPanel.tsx +134 -0
- data/frontend/src/components/POCard.tsx +56 -0
- data/frontend/src/components/PODetail.tsx +117 -0
- data/frontend/src/components/PromptPanel.tsx +51 -0
- data/frontend/src/components/SessionsPanel.tsx +174 -0
- data/frontend/src/components/ThreadsSidebar.tsx +119 -0
- data/frontend/src/components/index.ts +11 -0
- data/frontend/src/hooks/useWebSocket.ts +363 -0
- data/frontend/src/index.css +37 -0
- data/frontend/src/main.tsx +10 -0
- data/frontend/src/store/index.ts +246 -0
- data/frontend/src/types/index.ts +146 -0
- data/frontend/tailwind.config.js +25 -0
- data/frontend/tsconfig.json +30 -0
- data/frontend/vite.config.ts +29 -0
- data/lib/prompt_objects/capability.rb +46 -0
- data/lib/prompt_objects/cli.rb +431 -0
- data/lib/prompt_objects/connectors/base.rb +73 -0
- data/lib/prompt_objects/connectors/mcp.rb +524 -0
- data/lib/prompt_objects/environment/exporter.rb +83 -0
- data/lib/prompt_objects/environment/git.rb +118 -0
- data/lib/prompt_objects/environment/importer.rb +159 -0
- data/lib/prompt_objects/environment/manager.rb +401 -0
- data/lib/prompt_objects/environment/manifest.rb +218 -0
- data/lib/prompt_objects/environment.rb +283 -0
- data/lib/prompt_objects/human_queue.rb +144 -0
- data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
- data/lib/prompt_objects/llm/factory.rb +84 -0
- data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
- data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
- data/lib/prompt_objects/llm/response.rb +61 -0
- data/lib/prompt_objects/loader.rb +32 -0
- data/lib/prompt_objects/mcp/server.rb +167 -0
- data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
- data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
- data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
- data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
- data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
- data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
- data/lib/prompt_objects/message_bus.rb +97 -0
- data/lib/prompt_objects/primitive.rb +13 -0
- data/lib/prompt_objects/primitives/http_get.rb +72 -0
- data/lib/prompt_objects/primitives/list_files.rb +95 -0
- data/lib/prompt_objects/primitives/read_file.rb +81 -0
- data/lib/prompt_objects/primitives/write_file.rb +73 -0
- data/lib/prompt_objects/prompt_object.rb +415 -0
- data/lib/prompt_objects/registry.rb +88 -0
- data/lib/prompt_objects/server/api/routes.rb +297 -0
- data/lib/prompt_objects/server/app.rb +174 -0
- data/lib/prompt_objects/server/file_watcher.rb +113 -0
- data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
- data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
- data/lib/prompt_objects/server/public/index.html +14 -0
- data/lib/prompt_objects/server/websocket_handler.rb +619 -0
- data/lib/prompt_objects/server.rb +166 -0
- data/lib/prompt_objects/session/store.rb +826 -0
- data/lib/prompt_objects/universal/add_capability.rb +74 -0
- data/lib/prompt_objects/universal/add_primitive.rb +113 -0
- data/lib/prompt_objects/universal/ask_human.rb +109 -0
- data/lib/prompt_objects/universal/create_capability.rb +219 -0
- data/lib/prompt_objects/universal/create_primitive.rb +170 -0
- data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
- data/lib/prompt_objects/universal/list_primitives.rb +145 -0
- data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
- data/lib/prompt_objects/universal/request_primitive.rb +287 -0
- data/lib/prompt_objects/universal/think.rb +41 -0
- data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
- data/lib/prompt_objects.rb +62 -0
- data/objects/coordinator.md +48 -0
- data/objects/greeter.md +30 -0
- data/objects/reader.md +33 -0
- data/prompt_objects.gemspec +50 -0
- data/templates/basic/.gitignore +2 -0
- data/templates/basic/manifest.yml +7 -0
- data/templates/basic/objects/basic.md +32 -0
- data/templates/developer/.gitignore +5 -0
- data/templates/developer/manifest.yml +17 -0
- data/templates/developer/objects/code_reviewer.md +33 -0
- data/templates/developer/objects/coordinator.md +39 -0
- data/templates/developer/objects/debugger.md +35 -0
- data/templates/empty/.gitignore +5 -0
- data/templates/empty/manifest.yml +14 -0
- data/templates/empty/objects/.gitkeep +0 -0
- data/templates/empty/objects/assistant.md +41 -0
- data/templates/minimal/.gitignore +5 -0
- data/templates/minimal/manifest.yml +7 -0
- data/templates/minimal/objects/assistant.md +41 -0
- data/templates/writer/.gitignore +5 -0
- data/templates/writer/manifest.yml +17 -0
- data/templates/writer/objects/coordinator.md +33 -0
- data/templates/writer/objects/editor.md +33 -0
- data/templates/writer/objects/researcher.md +34 -0
- metadata +343 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module PromptObjects
|
|
8
|
+
module Session
|
|
9
|
+
# SQLite-based session storage for conversation history.
|
|
10
|
+
# Each environment has its own sessions.db file (gitignored for privacy).
|
|
11
|
+
class Store
|
|
12
|
+
SCHEMA_VERSION = 4
|
|
13
|
+
|
|
14
|
+
# Thread types for conversation branching
|
|
15
|
+
THREAD_TYPES = %w[root continuation delegation fork].freeze
|
|
16
|
+
|
|
17
|
+
# Valid source values for session tracking
|
|
18
|
+
SOURCES = %w[tui mcp api web cli].freeze
|
|
19
|
+
|
|
20
|
+
# @param db_path [String] Path to the SQLite database file
|
|
21
|
+
def initialize(db_path)
|
|
22
|
+
@db_path = db_path
|
|
23
|
+
@db = SQLite3::Database.new(db_path)
|
|
24
|
+
@db.results_as_hash = true
|
|
25
|
+
|
|
26
|
+
# Enable WAL mode for better concurrent access (TUI + MCP can access simultaneously)
|
|
27
|
+
@db.execute("PRAGMA journal_mode=WAL")
|
|
28
|
+
|
|
29
|
+
# Set busy timeout to 5 seconds - wait for locks instead of failing immediately
|
|
30
|
+
@db.busy_timeout = 5000
|
|
31
|
+
|
|
32
|
+
setup_schema
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Close the database connection.
|
|
36
|
+
def close
|
|
37
|
+
@db.close if @db
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# --- Session/Thread CRUD ---
|
|
41
|
+
|
|
42
|
+
# Create a new session (thread) for a PO.
|
|
43
|
+
# @param po_name [String] Name of the prompt object
|
|
44
|
+
# @param name [String, nil] Optional session name
|
|
45
|
+
# @param source [String] Source interface (tui, mcp, api, web, cli)
|
|
46
|
+
# @param source_client [String, nil] Client identifier (e.g., "claude-desktop", "cursor")
|
|
47
|
+
# @param metadata [Hash] Optional metadata
|
|
48
|
+
# @param parent_session_id [String, nil] Parent thread ID (for branching)
|
|
49
|
+
# @param parent_message_id [Integer, nil] Message ID that spawned this thread
|
|
50
|
+
# @param parent_po [String, nil] PO that created this thread (for cross-PO delegation)
|
|
51
|
+
# @param thread_type [String] Type of thread: root, continuation, delegation, fork
|
|
52
|
+
# @return [String] Session ID
|
|
53
|
+
def create_session(po_name:, name: nil, source: "tui", source_client: nil, metadata: {},
|
|
54
|
+
parent_session_id: nil, parent_message_id: nil, parent_po: nil, thread_type: "root")
|
|
55
|
+
id = SecureRandom.uuid
|
|
56
|
+
now = Time.now.utc.iso8601
|
|
57
|
+
|
|
58
|
+
@db.execute(<<~SQL, [id, po_name, name, source, source_client, source, now, now, metadata.to_json, parent_session_id, parent_message_id, parent_po, thread_type])
|
|
59
|
+
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)
|
|
60
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
61
|
+
SQL
|
|
62
|
+
|
|
63
|
+
id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Create a new thread (alias for create_session with thread semantics).
|
|
67
|
+
# @param po_name [String] Name of the prompt object
|
|
68
|
+
# @param parent_session_id [String, nil] Parent thread ID
|
|
69
|
+
# @param parent_message_id [Integer, nil] Message ID that spawned this thread
|
|
70
|
+
# @param parent_po [String, nil] PO that created this thread
|
|
71
|
+
# @param thread_type [String] Type: root, continuation, delegation, fork
|
|
72
|
+
# @param opts [Hash] Additional options passed to create_session
|
|
73
|
+
# @return [String] Thread/Session ID
|
|
74
|
+
def create_thread(po_name:, parent_session_id: nil, parent_message_id: nil, parent_po: nil, thread_type: "root", **opts)
|
|
75
|
+
create_session(
|
|
76
|
+
po_name: po_name,
|
|
77
|
+
parent_session_id: parent_session_id,
|
|
78
|
+
parent_message_id: parent_message_id,
|
|
79
|
+
parent_po: parent_po,
|
|
80
|
+
thread_type: thread_type,
|
|
81
|
+
**opts
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get a session by ID.
|
|
86
|
+
# @param id [String] Session ID
|
|
87
|
+
# @return [Hash, nil] Session data or nil if not found
|
|
88
|
+
def get_session(id)
|
|
89
|
+
row = @db.get_first_row("SELECT * FROM sessions WHERE id = ?", [id])
|
|
90
|
+
return nil unless row
|
|
91
|
+
|
|
92
|
+
parse_session_row(row)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get the most recent session for a PO, or create one if none exists.
|
|
96
|
+
# @param po_name [String] Name of the prompt object
|
|
97
|
+
# @param source [String] Source interface for new session
|
|
98
|
+
# @param source_client [String, nil] Client identifier for new session
|
|
99
|
+
# @return [Hash] Session data
|
|
100
|
+
def get_or_create_session(po_name:, source: "tui", source_client: nil)
|
|
101
|
+
session = get_latest_session(po_name: po_name)
|
|
102
|
+
return session if session
|
|
103
|
+
|
|
104
|
+
id = create_session(po_name: po_name, source: source, source_client: source_client)
|
|
105
|
+
get_session(id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the most recent session for a PO.
|
|
109
|
+
# @param po_name [String] Name of the prompt object
|
|
110
|
+
# @return [Hash, nil] Session data or nil
|
|
111
|
+
def get_latest_session(po_name:)
|
|
112
|
+
row = @db.get_first_row(<<~SQL, [po_name])
|
|
113
|
+
SELECT * FROM sessions
|
|
114
|
+
WHERE po_name = ?
|
|
115
|
+
ORDER BY updated_at DESC
|
|
116
|
+
LIMIT 1
|
|
117
|
+
SQL
|
|
118
|
+
|
|
119
|
+
return nil unless row
|
|
120
|
+
|
|
121
|
+
parse_session_row(row)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# List all sessions for a PO.
|
|
125
|
+
# @param po_name [String] Name of the prompt object
|
|
126
|
+
# @return [Array<Hash>] Session data with message counts
|
|
127
|
+
def list_sessions(po_name:)
|
|
128
|
+
rows = @db.execute(<<~SQL, [po_name])
|
|
129
|
+
SELECT s.*, COUNT(m.id) as message_count
|
|
130
|
+
FROM sessions s
|
|
131
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
132
|
+
WHERE s.po_name = ?
|
|
133
|
+
GROUP BY s.id
|
|
134
|
+
ORDER BY s.updated_at DESC
|
|
135
|
+
SQL
|
|
136
|
+
|
|
137
|
+
rows.map { |row| parse_session_row(row, include_count: true) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# List all sessions across all POs.
|
|
141
|
+
# @param source [String, nil] Filter by source interface
|
|
142
|
+
# @param limit [Integer, nil] Maximum number of sessions
|
|
143
|
+
# @return [Array<Hash>] Session data with message counts
|
|
144
|
+
def list_all_sessions(source: nil, limit: nil)
|
|
145
|
+
sql = <<~SQL
|
|
146
|
+
SELECT s.*, COUNT(m.id) as message_count
|
|
147
|
+
FROM sessions s
|
|
148
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
149
|
+
SQL
|
|
150
|
+
|
|
151
|
+
params = []
|
|
152
|
+
if source
|
|
153
|
+
sql += " WHERE s.source = ?"
|
|
154
|
+
params << source
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
sql += " GROUP BY s.id ORDER BY s.updated_at DESC"
|
|
158
|
+
|
|
159
|
+
if limit
|
|
160
|
+
sql += " LIMIT ?"
|
|
161
|
+
params << limit
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
rows = @db.execute(sql, params)
|
|
165
|
+
rows.map { |row| parse_session_row(row, include_count: true) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Thread Navigation ---
|
|
169
|
+
|
|
170
|
+
# Get all child threads of a session.
|
|
171
|
+
# @param session_id [String] Parent session ID
|
|
172
|
+
# @return [Array<Hash>] Child session data with message counts
|
|
173
|
+
def get_child_threads(session_id)
|
|
174
|
+
rows = @db.execute(<<~SQL, [session_id])
|
|
175
|
+
SELECT s.*, COUNT(m.id) as message_count
|
|
176
|
+
FROM sessions s
|
|
177
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
178
|
+
WHERE s.parent_session_id = ?
|
|
179
|
+
GROUP BY s.id
|
|
180
|
+
ORDER BY s.created_at ASC
|
|
181
|
+
SQL
|
|
182
|
+
|
|
183
|
+
rows.map { |row| parse_session_row(row, include_count: true) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get the lineage (path from root) for a thread.
|
|
187
|
+
# @param session_id [String] Session ID
|
|
188
|
+
# @return [Array<Hash>] Ancestors from root to current (inclusive)
|
|
189
|
+
def get_thread_lineage(session_id)
|
|
190
|
+
lineage = []
|
|
191
|
+
current_id = session_id
|
|
192
|
+
|
|
193
|
+
while current_id
|
|
194
|
+
session = get_session(current_id)
|
|
195
|
+
break unless session
|
|
196
|
+
|
|
197
|
+
lineage.unshift(session)
|
|
198
|
+
current_id = session[:parent_session_id]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
lineage
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Get the full thread tree starting from a session.
|
|
205
|
+
# @param session_id [String] Root session ID
|
|
206
|
+
# @param max_depth [Integer] Maximum recursion depth
|
|
207
|
+
# @return [Hash] Tree structure with session and children
|
|
208
|
+
def get_thread_tree(session_id, max_depth: 10)
|
|
209
|
+
return nil if max_depth <= 0
|
|
210
|
+
|
|
211
|
+
session = get_session(session_id)
|
|
212
|
+
return nil unless session
|
|
213
|
+
|
|
214
|
+
# Add message count
|
|
215
|
+
session[:message_count] = message_count(session_id)
|
|
216
|
+
|
|
217
|
+
children = get_child_threads(session_id)
|
|
218
|
+
child_trees = children.map { |child| get_thread_tree(child[:id], max_depth: max_depth - 1) }.compact
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
session: session,
|
|
222
|
+
children: child_trees
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Get root threads for a PO (threads with no parent).
|
|
227
|
+
# @param po_name [String] Name of the prompt object
|
|
228
|
+
# @return [Array<Hash>] Root session data with message counts
|
|
229
|
+
def get_root_threads(po_name:)
|
|
230
|
+
rows = @db.execute(<<~SQL, [po_name])
|
|
231
|
+
SELECT s.*, COUNT(m.id) as message_count
|
|
232
|
+
FROM sessions s
|
|
233
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
234
|
+
WHERE s.po_name = ? AND s.parent_session_id IS NULL
|
|
235
|
+
GROUP BY s.id
|
|
236
|
+
ORDER BY s.updated_at DESC
|
|
237
|
+
SQL
|
|
238
|
+
|
|
239
|
+
rows.map { |row| parse_session_row(row, include_count: true) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Auto-generate a name for a thread from its first message.
|
|
243
|
+
# @param session_id [String] Session ID
|
|
244
|
+
# @param first_message [String] First message content
|
|
245
|
+
# @param max_length [Integer] Maximum name length
|
|
246
|
+
def auto_name_thread(session_id, first_message, max_length: 40)
|
|
247
|
+
return unless first_message
|
|
248
|
+
|
|
249
|
+
auto_name = first_message.to_s.gsub(/\s+/, " ").strip[0, max_length]
|
|
250
|
+
auto_name += "..." if first_message.to_s.length > max_length
|
|
251
|
+
update_session(session_id, name: auto_name)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Search sessions by message content using full-text search.
|
|
255
|
+
# @param query [String] Search query
|
|
256
|
+
# @param po_name [String, nil] Filter by PO
|
|
257
|
+
# @param source [String, nil] Filter by source
|
|
258
|
+
# @param limit [Integer] Maximum results
|
|
259
|
+
# @return [Array<Hash>] Sessions with match info
|
|
260
|
+
def search_sessions(query, po_name: nil, source: nil, limit: 50)
|
|
261
|
+
return [] if query.nil? || query.strip.empty?
|
|
262
|
+
|
|
263
|
+
# Use FTS5 MATCH syntax
|
|
264
|
+
# Escape special characters and add prefix matching
|
|
265
|
+
safe_query = query.gsub(/['"()]/, " ").strip
|
|
266
|
+
fts_query = safe_query.split.map { |term| "#{term}*" }.join(" ")
|
|
267
|
+
|
|
268
|
+
# First get matching message IDs from FTS
|
|
269
|
+
sql = <<~SQL
|
|
270
|
+
SELECT DISTINCT s.*, COUNT(m.id) as message_count
|
|
271
|
+
FROM sessions s
|
|
272
|
+
INNER JOIN messages m ON m.session_id = s.id
|
|
273
|
+
INNER JOIN messages_fts ON messages_fts.rowid = m.id
|
|
274
|
+
WHERE messages_fts MATCH ?
|
|
275
|
+
SQL
|
|
276
|
+
|
|
277
|
+
params = [fts_query]
|
|
278
|
+
|
|
279
|
+
if po_name
|
|
280
|
+
sql += " AND s.po_name = ?"
|
|
281
|
+
params << po_name
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
if source
|
|
285
|
+
sql += " AND s.source = ?"
|
|
286
|
+
params << source
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
sql += " GROUP BY s.id ORDER BY s.updated_at DESC LIMIT ?"
|
|
290
|
+
params << limit
|
|
291
|
+
|
|
292
|
+
rows = @db.execute(sql, params)
|
|
293
|
+
results = rows.map { |row| parse_session_row(row, include_count: true) }
|
|
294
|
+
|
|
295
|
+
# Get a snippet from matching messages for each session
|
|
296
|
+
results.each do |session|
|
|
297
|
+
snippet = get_match_snippet(session[:id], query)
|
|
298
|
+
session[:match_snippet] = snippet if snippet
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
results
|
|
302
|
+
rescue SQLite3::SQLException => e
|
|
303
|
+
# FTS table might not exist in older databases
|
|
304
|
+
if e.message.include?("no such table")
|
|
305
|
+
search_sessions_fallback(query, po_name: po_name, source: source, limit: limit)
|
|
306
|
+
else
|
|
307
|
+
raise
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Get a snippet of matching content from a session
|
|
312
|
+
# @param session_id [String] Session ID
|
|
313
|
+
# @param query [String] Search query
|
|
314
|
+
# @return [String, nil] Snippet with match highlighted
|
|
315
|
+
def get_match_snippet(session_id, query)
|
|
316
|
+
# Get first message that matches
|
|
317
|
+
row = @db.get_first_row(<<~SQL, [session_id, "%#{query}%"])
|
|
318
|
+
SELECT content FROM messages
|
|
319
|
+
WHERE session_id = ? AND content LIKE ?
|
|
320
|
+
LIMIT 1
|
|
321
|
+
SQL
|
|
322
|
+
|
|
323
|
+
return nil unless row && row["content"]
|
|
324
|
+
|
|
325
|
+
content = row["content"]
|
|
326
|
+
# Find match position and extract snippet
|
|
327
|
+
query_lower = query.downcase
|
|
328
|
+
pos = content.downcase.index(query_lower)
|
|
329
|
+
return content[0, 60] + "..." unless pos
|
|
330
|
+
|
|
331
|
+
# Extract snippet around match
|
|
332
|
+
start_pos = [pos - 20, 0].max
|
|
333
|
+
end_pos = [pos + query.length + 40, content.length].min
|
|
334
|
+
snippet = content[start_pos, end_pos - start_pos]
|
|
335
|
+
|
|
336
|
+
# Add ellipsis if truncated
|
|
337
|
+
snippet = "..." + snippet if start_pos > 0
|
|
338
|
+
snippet = snippet + "..." if end_pos < content.length
|
|
339
|
+
|
|
340
|
+
# Highlight match with markers
|
|
341
|
+
snippet.gsub(/#{Regexp.escape(query)}/i) { |m| ">>>#{m}<<<" }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Fallback search without FTS (slower but works on older databases)
|
|
345
|
+
# @param query [String] Search query
|
|
346
|
+
# @param po_name [String, nil] Filter by PO
|
|
347
|
+
# @param source [String, nil] Filter by source
|
|
348
|
+
# @param limit [Integer] Maximum results
|
|
349
|
+
# @return [Array<Hash>] Sessions with match info
|
|
350
|
+
def search_sessions_fallback(query, po_name: nil, source: nil, limit: 50)
|
|
351
|
+
return [] if query.nil? || query.strip.empty?
|
|
352
|
+
|
|
353
|
+
sql = <<~SQL
|
|
354
|
+
SELECT DISTINCT s.*, COUNT(m.id) as message_count
|
|
355
|
+
FROM sessions s
|
|
356
|
+
INNER JOIN messages m ON m.session_id = s.id
|
|
357
|
+
WHERE m.content LIKE ?
|
|
358
|
+
SQL
|
|
359
|
+
|
|
360
|
+
params = ["%#{query}%"]
|
|
361
|
+
|
|
362
|
+
if po_name
|
|
363
|
+
sql += " AND s.po_name = ?"
|
|
364
|
+
params << po_name
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if source
|
|
368
|
+
sql += " AND s.source = ?"
|
|
369
|
+
params << source
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
sql += " GROUP BY s.id ORDER BY s.updated_at DESC LIMIT ?"
|
|
373
|
+
params << limit
|
|
374
|
+
|
|
375
|
+
rows = @db.execute(sql, params)
|
|
376
|
+
rows.map { |row| parse_session_row(row, include_count: true) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Update a session's metadata.
|
|
380
|
+
# @param id [String] Session ID
|
|
381
|
+
# @param name [String, nil] New session name
|
|
382
|
+
# @param metadata [Hash, nil] New metadata (merged with existing)
|
|
383
|
+
# @param last_message_source [String, nil] Source of last message (tui, mcp, api)
|
|
384
|
+
def update_session(id, name: nil, metadata: nil, last_message_source: nil)
|
|
385
|
+
updates = ["updated_at = ?"]
|
|
386
|
+
params = [Time.now.utc.iso8601]
|
|
387
|
+
|
|
388
|
+
if name
|
|
389
|
+
updates << "name = ?"
|
|
390
|
+
params << name
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
if last_message_source
|
|
394
|
+
updates << "last_message_source = ?"
|
|
395
|
+
params << last_message_source
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
if metadata
|
|
399
|
+
# Merge with existing metadata
|
|
400
|
+
existing = get_session(id)
|
|
401
|
+
if existing
|
|
402
|
+
merged = (existing[:metadata] || {}).merge(metadata)
|
|
403
|
+
updates << "metadata = ?"
|
|
404
|
+
params << merged.to_json
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
params << id
|
|
409
|
+
@db.execute("UPDATE sessions SET #{updates.join(', ')} WHERE id = ?", params)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Delete a session and all its messages.
|
|
413
|
+
# @param id [String] Session ID
|
|
414
|
+
def delete_session(id)
|
|
415
|
+
@db.execute("DELETE FROM messages WHERE session_id = ?", [id])
|
|
416
|
+
@db.execute("DELETE FROM sessions WHERE id = ?", [id])
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# --- Message CRUD ---
|
|
420
|
+
|
|
421
|
+
# Add a message to a session.
|
|
422
|
+
# @param session_id [String] Session ID
|
|
423
|
+
# @param role [Symbol, String] Message role (:user, :assistant, :tool)
|
|
424
|
+
# @param content [String, nil] Message content
|
|
425
|
+
# @param from_po [String, nil] Source PO for delegation tracking
|
|
426
|
+
# @param tool_calls [Array, nil] Tool calls data
|
|
427
|
+
# @param tool_results [Array, nil] Tool results data
|
|
428
|
+
# @param source [String, nil] Source interface that added this message
|
|
429
|
+
# @return [Integer] Message ID
|
|
430
|
+
def add_message(session_id:, role:, content: nil, from_po: nil, tool_calls: nil, tool_results: nil, source: nil)
|
|
431
|
+
now = Time.now.utc.iso8601
|
|
432
|
+
|
|
433
|
+
params = [
|
|
434
|
+
session_id,
|
|
435
|
+
role.to_s,
|
|
436
|
+
content,
|
|
437
|
+
from_po,
|
|
438
|
+
tool_calls&.to_json,
|
|
439
|
+
tool_results&.to_json,
|
|
440
|
+
now
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
@db.execute(<<~SQL, params)
|
|
444
|
+
INSERT INTO messages (session_id, role, content, from_po, tool_calls, tool_results, created_at)
|
|
445
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
446
|
+
SQL
|
|
447
|
+
|
|
448
|
+
# Update session's updated_at and optionally last_message_source
|
|
449
|
+
if source
|
|
450
|
+
@db.execute("UPDATE sessions SET updated_at = ?, last_message_source = ? WHERE id = ?", [now, source, session_id])
|
|
451
|
+
else
|
|
452
|
+
@db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, session_id])
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
@db.last_insert_row_id
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Get all messages for a session.
|
|
459
|
+
# @param session_id [String] Session ID
|
|
460
|
+
# @return [Array<Hash>] Messages in chronological order
|
|
461
|
+
def get_messages(session_id)
|
|
462
|
+
rows = @db.execute(<<~SQL, [session_id])
|
|
463
|
+
SELECT * FROM messages
|
|
464
|
+
WHERE session_id = ?
|
|
465
|
+
ORDER BY id ASC
|
|
466
|
+
SQL
|
|
467
|
+
|
|
468
|
+
rows.map { |row| parse_message_row(row) }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Get message count for a session.
|
|
472
|
+
# @param session_id [String] Session ID
|
|
473
|
+
# @return [Integer]
|
|
474
|
+
def message_count(session_id)
|
|
475
|
+
row = @db.get_first_row("SELECT COUNT(*) as count FROM messages WHERE session_id = ?", [session_id])
|
|
476
|
+
row["count"]
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Clear all messages from a session (but keep the session).
|
|
480
|
+
# @param session_id [String] Session ID
|
|
481
|
+
def clear_messages(session_id)
|
|
482
|
+
@db.execute("DELETE FROM messages WHERE session_id = ?", [session_id])
|
|
483
|
+
@db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", [Time.now.utc.iso8601, session_id])
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# --- Stats ---
|
|
487
|
+
|
|
488
|
+
# Get total message count across all sessions.
|
|
489
|
+
# @return [Integer]
|
|
490
|
+
def total_messages
|
|
491
|
+
row = @db.get_first_row("SELECT COUNT(*) as count FROM messages")
|
|
492
|
+
row["count"]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Get total session count.
|
|
496
|
+
# @return [Integer]
|
|
497
|
+
def total_sessions
|
|
498
|
+
row = @db.get_first_row("SELECT COUNT(*) as count FROM sessions")
|
|
499
|
+
row["count"]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# --- Export ---
|
|
503
|
+
|
|
504
|
+
# Export a session to JSON format.
|
|
505
|
+
# @param session_id [String] Session ID
|
|
506
|
+
# @return [Hash] Session data with messages
|
|
507
|
+
def export_session_json(session_id)
|
|
508
|
+
session = get_session(session_id)
|
|
509
|
+
return nil unless session
|
|
510
|
+
|
|
511
|
+
messages = get_messages(session_id)
|
|
512
|
+
|
|
513
|
+
{
|
|
514
|
+
id: session[:id],
|
|
515
|
+
po_name: session[:po_name],
|
|
516
|
+
name: session[:name],
|
|
517
|
+
source: session[:source],
|
|
518
|
+
source_client: session[:source_client],
|
|
519
|
+
created_at: session[:created_at]&.iso8601,
|
|
520
|
+
updated_at: session[:updated_at]&.iso8601,
|
|
521
|
+
metadata: session[:metadata],
|
|
522
|
+
messages: messages.map do |m|
|
|
523
|
+
{
|
|
524
|
+
role: m[:role].to_s,
|
|
525
|
+
content: m[:content],
|
|
526
|
+
from_po: m[:from_po],
|
|
527
|
+
tool_calls: m[:tool_calls],
|
|
528
|
+
tool_results: m[:tool_results],
|
|
529
|
+
created_at: m[:created_at]&.iso8601
|
|
530
|
+
}
|
|
531
|
+
end
|
|
532
|
+
}
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Export a session to Markdown format.
|
|
536
|
+
# @param session_id [String] Session ID
|
|
537
|
+
# @return [String] Markdown content
|
|
538
|
+
def export_session_markdown(session_id)
|
|
539
|
+
session = get_session(session_id)
|
|
540
|
+
return nil unless session
|
|
541
|
+
|
|
542
|
+
messages = get_messages(session_id)
|
|
543
|
+
|
|
544
|
+
lines = []
|
|
545
|
+
lines << "# Session: #{session[:name] || 'Unnamed'}"
|
|
546
|
+
lines << ""
|
|
547
|
+
lines << "- **PO**: #{session[:po_name]}"
|
|
548
|
+
lines << "- **Source**: #{session[:source]}"
|
|
549
|
+
lines << "- **Created**: #{session[:created_at]&.strftime('%Y-%m-%d %H:%M')}"
|
|
550
|
+
lines << "- **Updated**: #{session[:updated_at]&.strftime('%Y-%m-%d %H:%M')}"
|
|
551
|
+
lines << "- **Messages**: #{messages.length}"
|
|
552
|
+
lines << ""
|
|
553
|
+
lines << "---"
|
|
554
|
+
lines << ""
|
|
555
|
+
|
|
556
|
+
messages.each do |m|
|
|
557
|
+
timestamp = m[:created_at]&.strftime('%H:%M')
|
|
558
|
+
role_label = case m[:role].to_s
|
|
559
|
+
when "user" then "**User**"
|
|
560
|
+
when "assistant" then "**#{m[:from_po] || session[:po_name]}**"
|
|
561
|
+
when "tool" then "*Tool*"
|
|
562
|
+
else "**#{m[:role]}**"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
lines << "#{role_label} (#{timestamp}):"
|
|
566
|
+
lines << ""
|
|
567
|
+
|
|
568
|
+
if m[:content]
|
|
569
|
+
lines << m[:content]
|
|
570
|
+
lines << ""
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
if m[:tool_calls]
|
|
574
|
+
lines << "<details><summary>Tool calls</summary>"
|
|
575
|
+
lines << ""
|
|
576
|
+
lines << "```json"
|
|
577
|
+
lines << JSON.pretty_generate(m[:tool_calls])
|
|
578
|
+
lines << "```"
|
|
579
|
+
lines << "</details>"
|
|
580
|
+
lines << ""
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
if m[:tool_results]
|
|
584
|
+
lines << "<details><summary>Tool results</summary>"
|
|
585
|
+
lines << ""
|
|
586
|
+
lines << "```json"
|
|
587
|
+
lines << JSON.pretty_generate(m[:tool_results])
|
|
588
|
+
lines << "```"
|
|
589
|
+
lines << "</details>"
|
|
590
|
+
lines << ""
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
lines << "---"
|
|
594
|
+
lines << ""
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
lines.join("\n")
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Export all sessions for a PO.
|
|
601
|
+
# @param po_name [String] PO name
|
|
602
|
+
# @param format [Symbol] :json or :markdown
|
|
603
|
+
# @return [String] Exported content
|
|
604
|
+
def export_all_sessions(po_name:, format: :json)
|
|
605
|
+
sessions = list_sessions(po_name: po_name)
|
|
606
|
+
|
|
607
|
+
case format
|
|
608
|
+
when :json
|
|
609
|
+
exported = sessions.map { |s| export_session_json(s[:id]) }
|
|
610
|
+
JSON.pretty_generate(exported)
|
|
611
|
+
when :markdown
|
|
612
|
+
sessions.map { |s| export_session_markdown(s[:id]) }.join("\n\n")
|
|
613
|
+
else
|
|
614
|
+
raise ArgumentError, "Unknown format: #{format}"
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# --- Import ---
|
|
619
|
+
|
|
620
|
+
# Import a session from JSON data.
|
|
621
|
+
# @param data [Hash] Session data (as returned by export_session_json)
|
|
622
|
+
# @param po_name [String, nil] Override PO name
|
|
623
|
+
# @return [String] New session ID
|
|
624
|
+
def import_session(data, po_name: nil)
|
|
625
|
+
data = data.transform_keys(&:to_sym) if data.is_a?(Hash)
|
|
626
|
+
|
|
627
|
+
# Create new session with new ID
|
|
628
|
+
new_id = create_session(
|
|
629
|
+
po_name: po_name || data[:po_name],
|
|
630
|
+
name: "#{data[:name]} (imported)",
|
|
631
|
+
source: "tui",
|
|
632
|
+
metadata: (data[:metadata] || {}).merge(
|
|
633
|
+
imported_from: data[:id],
|
|
634
|
+
imported_at: Time.now.utc.iso8601,
|
|
635
|
+
original_source: data[:source]
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Import messages
|
|
640
|
+
messages = data[:messages] || []
|
|
641
|
+
messages.each do |m|
|
|
642
|
+
m = m.transform_keys(&:to_sym) if m.is_a?(Hash)
|
|
643
|
+
add_message(
|
|
644
|
+
session_id: new_id,
|
|
645
|
+
role: m[:role],
|
|
646
|
+
content: m[:content],
|
|
647
|
+
from_po: m[:from_po],
|
|
648
|
+
tool_calls: m[:tool_calls],
|
|
649
|
+
tool_results: m[:tool_results]
|
|
650
|
+
)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
new_id
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
private
|
|
657
|
+
|
|
658
|
+
def setup_schema
|
|
659
|
+
# Check if we need to create/migrate
|
|
660
|
+
version = get_schema_version
|
|
661
|
+
|
|
662
|
+
if version == 0
|
|
663
|
+
create_schema
|
|
664
|
+
set_schema_version(SCHEMA_VERSION)
|
|
665
|
+
elsif version < SCHEMA_VERSION
|
|
666
|
+
migrate_schema(version)
|
|
667
|
+
set_schema_version(SCHEMA_VERSION)
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def get_schema_version
|
|
672
|
+
@db.get_first_value("PRAGMA user_version") || 0
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def set_schema_version(version)
|
|
676
|
+
@db.execute("PRAGMA user_version = #{version}")
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def create_schema
|
|
680
|
+
@db.execute_batch(<<~SQL)
|
|
681
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
682
|
+
id TEXT PRIMARY KEY,
|
|
683
|
+
po_name TEXT NOT NULL,
|
|
684
|
+
name TEXT,
|
|
685
|
+
source TEXT DEFAULT 'tui',
|
|
686
|
+
source_client TEXT,
|
|
687
|
+
last_message_source TEXT,
|
|
688
|
+
created_at TEXT NOT NULL,
|
|
689
|
+
updated_at TEXT NOT NULL,
|
|
690
|
+
metadata TEXT DEFAULT '{}',
|
|
691
|
+
-- Thread/branching support (v4)
|
|
692
|
+
parent_session_id TEXT,
|
|
693
|
+
parent_message_id INTEGER,
|
|
694
|
+
parent_po TEXT,
|
|
695
|
+
thread_type TEXT DEFAULT 'root'
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_po_name ON sessions(po_name);
|
|
699
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
|
|
700
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
|
701
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
|
702
|
+
|
|
703
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
704
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
705
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
706
|
+
role TEXT NOT NULL,
|
|
707
|
+
content TEXT,
|
|
708
|
+
from_po TEXT,
|
|
709
|
+
tool_calls TEXT,
|
|
710
|
+
tool_results TEXT,
|
|
711
|
+
created_at TEXT NOT NULL
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
|
715
|
+
|
|
716
|
+
-- Full-text search index for message content
|
|
717
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
718
|
+
content,
|
|
719
|
+
content='messages',
|
|
720
|
+
content_rowid='id'
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
-- Triggers to keep FTS in sync
|
|
724
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
725
|
+
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
|
726
|
+
END;
|
|
727
|
+
|
|
728
|
+
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
729
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
730
|
+
END;
|
|
731
|
+
|
|
732
|
+
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
733
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
734
|
+
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
|
735
|
+
END;
|
|
736
|
+
SQL
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def migrate_schema(from_version)
|
|
740
|
+
if from_version < 2
|
|
741
|
+
# Add source tracking columns
|
|
742
|
+
@db.execute_batch(<<~SQL)
|
|
743
|
+
ALTER TABLE sessions ADD COLUMN source TEXT DEFAULT 'tui';
|
|
744
|
+
ALTER TABLE sessions ADD COLUMN source_client TEXT;
|
|
745
|
+
ALTER TABLE sessions ADD COLUMN last_message_source TEXT;
|
|
746
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
|
747
|
+
SQL
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
if from_version < 3
|
|
751
|
+
# Add full-text search for messages
|
|
752
|
+
@db.execute_batch(<<~SQL)
|
|
753
|
+
-- Full-text search index for message content
|
|
754
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
755
|
+
content,
|
|
756
|
+
content='messages',
|
|
757
|
+
content_rowid='id'
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
-- Triggers to keep FTS in sync
|
|
761
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
762
|
+
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
|
763
|
+
END;
|
|
764
|
+
|
|
765
|
+
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
766
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
767
|
+
END;
|
|
768
|
+
|
|
769
|
+
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
770
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
771
|
+
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
|
772
|
+
END;
|
|
773
|
+
SQL
|
|
774
|
+
|
|
775
|
+
# Populate FTS with existing messages
|
|
776
|
+
@db.execute("INSERT INTO messages_fts(rowid, content) SELECT id, content FROM messages WHERE content IS NOT NULL")
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
if from_version < 4
|
|
780
|
+
# Add thread/branching support columns
|
|
781
|
+
@db.execute_batch(<<~SQL)
|
|
782
|
+
ALTER TABLE sessions ADD COLUMN parent_session_id TEXT;
|
|
783
|
+
ALTER TABLE sessions ADD COLUMN parent_message_id INTEGER;
|
|
784
|
+
ALTER TABLE sessions ADD COLUMN parent_po TEXT;
|
|
785
|
+
ALTER TABLE sessions ADD COLUMN thread_type TEXT DEFAULT 'root';
|
|
786
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
|
787
|
+
SQL
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def parse_session_row(row, include_count: false)
|
|
792
|
+
result = {
|
|
793
|
+
id: row["id"],
|
|
794
|
+
po_name: row["po_name"],
|
|
795
|
+
name: row["name"],
|
|
796
|
+
source: row["source"] || "tui",
|
|
797
|
+
source_client: row["source_client"],
|
|
798
|
+
last_message_source: row["last_message_source"],
|
|
799
|
+
created_at: row["created_at"] ? Time.parse(row["created_at"]) : nil,
|
|
800
|
+
updated_at: row["updated_at"] ? Time.parse(row["updated_at"]) : nil,
|
|
801
|
+
metadata: row["metadata"] ? JSON.parse(row["metadata"], symbolize_names: true) : {},
|
|
802
|
+
# Thread fields (v4)
|
|
803
|
+
parent_session_id: row["parent_session_id"],
|
|
804
|
+
parent_message_id: row["parent_message_id"],
|
|
805
|
+
parent_po: row["parent_po"],
|
|
806
|
+
thread_type: row["thread_type"] || "root"
|
|
807
|
+
}
|
|
808
|
+
result[:message_count] = row["message_count"] if include_count && row["message_count"]
|
|
809
|
+
result
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def parse_message_row(row)
|
|
813
|
+
{
|
|
814
|
+
id: row["id"],
|
|
815
|
+
session_id: row["session_id"],
|
|
816
|
+
role: row["role"].to_sym,
|
|
817
|
+
content: row["content"],
|
|
818
|
+
from_po: row["from_po"],
|
|
819
|
+
tool_calls: row["tool_calls"] ? JSON.parse(row["tool_calls"], symbolize_names: true) : nil,
|
|
820
|
+
tool_results: row["tool_results"] ? JSON.parse(row["tool_results"], symbolize_names: true) : nil,
|
|
821
|
+
created_at: row["created_at"] ? Time.parse(row["created_at"]) : nil
|
|
822
|
+
}
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
end
|