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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +108 -0
  3. data/Gemfile +10 -0
  4. data/Gemfile.lock +231 -0
  5. data/IMPLEMENTATION_PLAN.md +1073 -0
  6. data/LICENSE +21 -0
  7. data/README.md +73 -0
  8. data/Rakefile +27 -0
  9. data/design-doc-v2.md +1232 -0
  10. data/exe/prompt_objects +572 -0
  11. data/exe/prompt_objects_mcp +34 -0
  12. data/frontend/.gitignore +3 -0
  13. data/frontend/index.html +13 -0
  14. data/frontend/package-lock.json +4417 -0
  15. data/frontend/package.json +32 -0
  16. data/frontend/postcss.config.js +6 -0
  17. data/frontend/src/App.tsx +95 -0
  18. data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
  19. data/frontend/src/components/ChatPanel.tsx +251 -0
  20. data/frontend/src/components/Dashboard.tsx +83 -0
  21. data/frontend/src/components/Header.tsx +141 -0
  22. data/frontend/src/components/MarkdownMessage.tsx +153 -0
  23. data/frontend/src/components/MessageBus.tsx +55 -0
  24. data/frontend/src/components/ModelSelector.tsx +112 -0
  25. data/frontend/src/components/NotificationPanel.tsx +134 -0
  26. data/frontend/src/components/POCard.tsx +56 -0
  27. data/frontend/src/components/PODetail.tsx +117 -0
  28. data/frontend/src/components/PromptPanel.tsx +51 -0
  29. data/frontend/src/components/SessionsPanel.tsx +174 -0
  30. data/frontend/src/components/ThreadsSidebar.tsx +119 -0
  31. data/frontend/src/components/index.ts +11 -0
  32. data/frontend/src/hooks/useWebSocket.ts +363 -0
  33. data/frontend/src/index.css +37 -0
  34. data/frontend/src/main.tsx +10 -0
  35. data/frontend/src/store/index.ts +246 -0
  36. data/frontend/src/types/index.ts +146 -0
  37. data/frontend/tailwind.config.js +25 -0
  38. data/frontend/tsconfig.json +30 -0
  39. data/frontend/vite.config.ts +29 -0
  40. data/lib/prompt_objects/capability.rb +46 -0
  41. data/lib/prompt_objects/cli.rb +431 -0
  42. data/lib/prompt_objects/connectors/base.rb +73 -0
  43. data/lib/prompt_objects/connectors/mcp.rb +524 -0
  44. data/lib/prompt_objects/environment/exporter.rb +83 -0
  45. data/lib/prompt_objects/environment/git.rb +118 -0
  46. data/lib/prompt_objects/environment/importer.rb +159 -0
  47. data/lib/prompt_objects/environment/manager.rb +401 -0
  48. data/lib/prompt_objects/environment/manifest.rb +218 -0
  49. data/lib/prompt_objects/environment.rb +283 -0
  50. data/lib/prompt_objects/human_queue.rb +144 -0
  51. data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
  52. data/lib/prompt_objects/llm/factory.rb +84 -0
  53. data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
  54. data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
  55. data/lib/prompt_objects/llm/response.rb +61 -0
  56. data/lib/prompt_objects/loader.rb +32 -0
  57. data/lib/prompt_objects/mcp/server.rb +167 -0
  58. data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
  59. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
  60. data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
  61. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
  62. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
  63. data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
  64. data/lib/prompt_objects/message_bus.rb +97 -0
  65. data/lib/prompt_objects/primitive.rb +13 -0
  66. data/lib/prompt_objects/primitives/http_get.rb +72 -0
  67. data/lib/prompt_objects/primitives/list_files.rb +95 -0
  68. data/lib/prompt_objects/primitives/read_file.rb +81 -0
  69. data/lib/prompt_objects/primitives/write_file.rb +73 -0
  70. data/lib/prompt_objects/prompt_object.rb +415 -0
  71. data/lib/prompt_objects/registry.rb +88 -0
  72. data/lib/prompt_objects/server/api/routes.rb +297 -0
  73. data/lib/prompt_objects/server/app.rb +174 -0
  74. data/lib/prompt_objects/server/file_watcher.rb +113 -0
  75. data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
  76. data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
  77. data/lib/prompt_objects/server/public/index.html +14 -0
  78. data/lib/prompt_objects/server/websocket_handler.rb +619 -0
  79. data/lib/prompt_objects/server.rb +166 -0
  80. data/lib/prompt_objects/session/store.rb +826 -0
  81. data/lib/prompt_objects/universal/add_capability.rb +74 -0
  82. data/lib/prompt_objects/universal/add_primitive.rb +113 -0
  83. data/lib/prompt_objects/universal/ask_human.rb +109 -0
  84. data/lib/prompt_objects/universal/create_capability.rb +219 -0
  85. data/lib/prompt_objects/universal/create_primitive.rb +170 -0
  86. data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
  87. data/lib/prompt_objects/universal/list_primitives.rb +145 -0
  88. data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
  89. data/lib/prompt_objects/universal/request_primitive.rb +287 -0
  90. data/lib/prompt_objects/universal/think.rb +41 -0
  91. data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
  92. data/lib/prompt_objects.rb +62 -0
  93. data/objects/coordinator.md +48 -0
  94. data/objects/greeter.md +30 -0
  95. data/objects/reader.md +33 -0
  96. data/prompt_objects.gemspec +50 -0
  97. data/templates/basic/.gitignore +2 -0
  98. data/templates/basic/manifest.yml +7 -0
  99. data/templates/basic/objects/basic.md +32 -0
  100. data/templates/developer/.gitignore +5 -0
  101. data/templates/developer/manifest.yml +17 -0
  102. data/templates/developer/objects/code_reviewer.md +33 -0
  103. data/templates/developer/objects/coordinator.md +39 -0
  104. data/templates/developer/objects/debugger.md +35 -0
  105. data/templates/empty/.gitignore +5 -0
  106. data/templates/empty/manifest.yml +14 -0
  107. data/templates/empty/objects/.gitkeep +0 -0
  108. data/templates/empty/objects/assistant.md +41 -0
  109. data/templates/minimal/.gitignore +5 -0
  110. data/templates/minimal/manifest.yml +7 -0
  111. data/templates/minimal/objects/assistant.md +41 -0
  112. data/templates/writer/.gitignore +5 -0
  113. data/templates/writer/manifest.yml +17 -0
  114. data/templates/writer/objects/coordinator.md +33 -0
  115. data/templates/writer/objects/editor.md +33 -0
  116. data/templates/writer/objects/researcher.md +34 -0
  117. 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