rails_console_ai 0.13.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/app/controllers/rails_console_ai/application_controller.rb +28 -0
  6. data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
  7. data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
  8. data/app/models/rails_console_ai/session.rb +23 -0
  9. data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
  10. data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
  11. data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/generators/rails_console_ai/install_generator.rb +26 -0
  14. data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
  15. data/lib/rails_console_ai/channel/base.rb +23 -0
  16. data/lib/rails_console_ai/channel/console.rb +457 -0
  17. data/lib/rails_console_ai/channel/slack.rb +182 -0
  18. data/lib/rails_console_ai/configuration.rb +185 -0
  19. data/lib/rails_console_ai/console_methods.rb +277 -0
  20. data/lib/rails_console_ai/context_builder.rb +120 -0
  21. data/lib/rails_console_ai/conversation_engine.rb +1142 -0
  22. data/lib/rails_console_ai/engine.rb +5 -0
  23. data/lib/rails_console_ai/executor.rb +461 -0
  24. data/lib/rails_console_ai/providers/anthropic.rb +122 -0
  25. data/lib/rails_console_ai/providers/base.rb +118 -0
  26. data/lib/rails_console_ai/providers/bedrock.rb +171 -0
  27. data/lib/rails_console_ai/providers/local.rb +112 -0
  28. data/lib/rails_console_ai/providers/openai.rb +114 -0
  29. data/lib/rails_console_ai/railtie.rb +34 -0
  30. data/lib/rails_console_ai/repl.rb +65 -0
  31. data/lib/rails_console_ai/safety_guards.rb +207 -0
  32. data/lib/rails_console_ai/session_logger.rb +90 -0
  33. data/lib/rails_console_ai/slack_bot.rb +473 -0
  34. data/lib/rails_console_ai/storage/base.rb +27 -0
  35. data/lib/rails_console_ai/storage/file_storage.rb +63 -0
  36. data/lib/rails_console_ai/tools/code_tools.rb +126 -0
  37. data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
  38. data/lib/rails_console_ai/tools/model_tools.rb +95 -0
  39. data/lib/rails_console_ai/tools/registry.rb +478 -0
  40. data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
  41. data/lib/rails_console_ai/version.rb +3 -0
  42. data/lib/rails_console_ai.rb +214 -0
  43. data/lib/tasks/rails_console_ai.rake +7 -0
  44. metadata +152 -0
@@ -0,0 +1,1142 @@
1
+ module RailsConsoleAI
2
+ class ConversationEngine
3
+ attr_reader :history, :total_input_tokens, :total_output_tokens,
4
+ :interactive_session_id, :session_name
5
+
6
+ RECENT_OUTPUTS_TO_KEEP = 2
7
+
8
+ def initialize(binding_context:, channel:, slack_thread_ts: nil)
9
+ @binding_context = binding_context
10
+ @channel = channel
11
+ @slack_thread_ts = slack_thread_ts
12
+ @executor = Executor.new(binding_context, channel: channel)
13
+ @provider = nil
14
+ @context_builder = nil
15
+ @context = nil
16
+ @history = []
17
+ @total_input_tokens = 0
18
+ @total_output_tokens = 0
19
+ @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
20
+ @interactive_session_id = nil
21
+ @session_name = nil
22
+ @interactive_query = nil
23
+ @interactive_start = nil
24
+ @last_interactive_code = nil
25
+ @last_interactive_output = nil
26
+ @last_interactive_result = nil
27
+ @last_interactive_executed = false
28
+ @compact_warned = false
29
+ @prior_duration_ms = 0
30
+ end
31
+
32
+ # --- Public API for channels ---
33
+
34
+ def one_shot(query)
35
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ console_capture = StringIO.new
37
+ exec_result = with_console_capture(console_capture) do
38
+ conversation = [{ role: :user, content: query }]
39
+ exec_result, code, executed = one_shot_round(conversation)
40
+
41
+ if executed && @executor.last_error && !@executor.last_safety_error
42
+ error_msg = "Code execution failed with error: #{@executor.last_error}"
43
+ error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
44
+ conversation << { role: :assistant, content: @_last_result_text }
45
+ conversation << { role: :user, content: error_msg }
46
+
47
+ @channel.display_dim(" Attempting to fix...")
48
+ exec_result, code, executed = one_shot_round(conversation)
49
+ end
50
+
51
+ @_last_log_attrs = {
52
+ query: query,
53
+ conversation: conversation,
54
+ mode: 'one_shot',
55
+ code_executed: code,
56
+ code_output: executed ? @executor.last_output : nil,
57
+ code_result: executed && exec_result ? exec_result.inspect : nil,
58
+ executed: executed,
59
+ start_time: start_time
60
+ }
61
+
62
+ exec_result
63
+ end
64
+
65
+ log_session(@_last_log_attrs.merge(console_output: console_capture.string))
66
+ exec_result
67
+ rescue Providers::ProviderError => e
68
+ @channel.display_error("RailsConsoleAI Error: #{e.message}")
69
+ nil
70
+ rescue => e
71
+ @channel.display_error("RailsConsoleAI Error: #{e.class}: #{e.message}")
72
+ nil
73
+ end
74
+
75
+ def explain(query)
76
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ console_capture = StringIO.new
78
+ with_console_capture(console_capture) do
79
+ result, _ = send_query(query)
80
+ track_usage(result)
81
+ @executor.display_response(result.text)
82
+ display_usage(result)
83
+
84
+ @_last_log_attrs = {
85
+ query: query,
86
+ conversation: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
87
+ mode: 'explain',
88
+ executed: false,
89
+ start_time: start_time
90
+ }
91
+ end
92
+
93
+ log_session(@_last_log_attrs.merge(console_output: console_capture.string))
94
+ nil
95
+ rescue Providers::ProviderError => e
96
+ @channel.display_error("RailsConsoleAI Error: #{e.message}")
97
+ nil
98
+ rescue => e
99
+ @channel.display_error("RailsConsoleAI Error: #{e.class}: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ def process_message(text)
104
+ # Initialize interactive state if not already set (first message in session)
105
+ init_interactive unless @interactive_start
106
+ @channel.log_input(text) if @channel.respond_to?(:log_input)
107
+ @interactive_query ||= text
108
+ @history << { role: :user, content: text }
109
+
110
+ status = send_and_execute
111
+ if status == :error
112
+ @channel.display_dim(" Attempting to fix...")
113
+ send_and_execute
114
+ end
115
+ end
116
+
117
+ def init_guide
118
+ storage = RailsConsoleAI.storage
119
+ existing_guide = begin
120
+ content = storage.read(RailsConsoleAI::GUIDE_KEY)
121
+ (content && !content.strip.empty?) ? content.strip : nil
122
+ rescue
123
+ nil
124
+ end
125
+
126
+ if existing_guide
127
+ @channel.display(" Existing guide found (#{existing_guide.length} chars). Will update.")
128
+ else
129
+ @channel.display(" No existing guide. Exploring the app...")
130
+ end
131
+
132
+ require 'rails_console_ai/tools/registry'
133
+ init_tools = Tools::Registry.new(mode: :init)
134
+ sys_prompt = init_system_prompt(existing_guide)
135
+ messages = [{ role: :user, content: "Explore this Rails application and generate the application guide." }]
136
+
137
+ original_timeout = RailsConsoleAI.configuration.timeout
138
+ RailsConsoleAI.configuration.timeout = [original_timeout, 120].max
139
+
140
+ result, _ = send_query_with_tools(messages, system_prompt: sys_prompt, tools_override: init_tools)
141
+
142
+ guide_text = result.text.to_s.strip
143
+ guide_text = guide_text.sub(/\A```(?:markdown)?\s*\n?/, '').sub(/\n?```\s*\z/, '')
144
+ guide_text = guide_text.sub(/\A.*?(?=^#\s)/m, '') if guide_text =~ /^#\s/m
145
+
146
+ if guide_text.empty?
147
+ @channel.display_warning(" No guide content generated.")
148
+ return nil
149
+ end
150
+
151
+ storage.write(RailsConsoleAI::GUIDE_KEY, guide_text)
152
+ path = storage.respond_to?(:root_path) ? File.join(storage.root_path, RailsConsoleAI::GUIDE_KEY) : RailsConsoleAI::GUIDE_KEY
153
+ $stdout.puts "\e[32m Guide saved to #{path} (#{guide_text.length} chars)\e[0m"
154
+ display_usage(result)
155
+ nil
156
+ rescue Interrupt
157
+ $stdout.puts "\n\e[33m Interrupted.\e[0m"
158
+ nil
159
+ rescue Providers::ProviderError => e
160
+ @channel.display_error("RailsConsoleAI Error: #{e.message}")
161
+ nil
162
+ rescue => e
163
+ @channel.display_error("RailsConsoleAI Error: #{e.class}: #{e.message}")
164
+ nil
165
+ ensure
166
+ RailsConsoleAI.configuration.timeout = original_timeout if original_timeout
167
+ end
168
+
169
+ # --- Interactive session management ---
170
+
171
+ def init_interactive
172
+ @interactive_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+ @executor.on_prompt = -> { log_interactive_turn }
174
+ @history = []
175
+ @total_input_tokens = 0
176
+ @total_output_tokens = 0
177
+ @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
178
+ @interactive_query = nil
179
+ @interactive_session_id = nil
180
+ @session_name = nil
181
+ @last_interactive_code = nil
182
+ @last_interactive_output = nil
183
+ @last_interactive_result = nil
184
+ @last_interactive_executed = false
185
+ @compact_warned = false
186
+ @prior_duration_ms = 0
187
+ end
188
+
189
+ def restore_session(session)
190
+ @history = JSON.parse(session.conversation, symbolize_names: true)
191
+ @interactive_session_id = session.id
192
+ @interactive_query = session.query
193
+ @session_name = session.name
194
+ @total_input_tokens = session.input_tokens || 0
195
+ @total_output_tokens = session.output_tokens || 0
196
+ @prior_duration_ms = session.duration_ms || 0
197
+
198
+ if session.model && (session.input_tokens.to_i > 0 || session.output_tokens.to_i > 0)
199
+ @token_usage[session.model][:input] = session.input_tokens.to_i
200
+ @token_usage[session.model][:output] = session.output_tokens.to_i
201
+ end
202
+ end
203
+
204
+ def set_interactive_query(text)
205
+ @interactive_query ||= text
206
+ end
207
+
208
+ def add_user_message(text)
209
+ @history << { role: :user, content: text }
210
+ end
211
+
212
+ def pop_last_message
213
+ @history.pop
214
+ end
215
+
216
+ def set_session_name(name)
217
+ @session_name = name
218
+ if @interactive_session_id
219
+ require 'rails_console_ai/session_logger'
220
+ SessionLogger.update(@interactive_session_id, name: name)
221
+ end
222
+ end
223
+
224
+ def execute_direct(raw_code)
225
+ exec_result = @executor.execute(raw_code)
226
+
227
+ output_parts = []
228
+ output_parts << "Output:\n#{@executor.last_output.strip}" if @executor.last_output && !@executor.last_output.strip.empty?
229
+ output_parts << "Return value: #{exec_result.inspect}" if exec_result
230
+
231
+ result_str = output_parts.join("\n\n")
232
+ result_str = result_str[0..1000] + '...' if result_str.length > 1000
233
+
234
+ context_msg = "User directly executed code: `#{raw_code}`"
235
+ context_msg += "\n#{result_str}" unless output_parts.empty?
236
+ output_id = output_parts.empty? ? nil : @executor.store_output(result_str)
237
+ @history << { role: :user, content: context_msg, output_id: output_id }
238
+
239
+ @interactive_query ||= "> #{raw_code}"
240
+ @last_interactive_code = raw_code
241
+ @last_interactive_output = @executor.last_output
242
+ @last_interactive_result = exec_result ? exec_result.inspect : nil
243
+ @last_interactive_executed = true
244
+ end
245
+
246
+ def send_and_execute
247
+ begin
248
+ result, tool_messages = send_query(nil, conversation: @history)
249
+ rescue Providers::ProviderError => e
250
+ if e.message.include?("prompt is too long") && @history.length >= 6
251
+ @channel.display_warning(" Context limit reached. Run /compact to reduce context size, then try again.")
252
+ else
253
+ @channel.display_error("RailsConsoleAI Error: #{e.class}: #{e.message}")
254
+ end
255
+ return :error
256
+ rescue Interrupt
257
+ $stdout.puts "\n\e[33m Aborted.\e[0m"
258
+ return :interrupted
259
+ end
260
+
261
+ track_usage(result)
262
+ return :cancelled if @channel.cancelled?
263
+ code = @executor.display_response(result.text)
264
+ display_usage(result, show_session: true)
265
+
266
+ log_interactive_turn
267
+
268
+ @history.concat(tool_messages) if tool_messages && !tool_messages.empty?
269
+ @history << { role: :assistant, content: result.text }
270
+
271
+ return :no_code unless code && !code.strip.empty?
272
+ return :cancelled if @channel.cancelled?
273
+
274
+ exec_result = if RailsConsoleAI.configuration.auto_execute
275
+ @executor.execute(code)
276
+ else
277
+ @executor.confirm_and_execute(code)
278
+ end
279
+
280
+ unless @executor.last_cancelled?
281
+ @last_interactive_code = code
282
+ @last_interactive_output = @executor.last_output
283
+ @last_interactive_result = exec_result ? exec_result.inspect : nil
284
+ @last_interactive_executed = true
285
+ end
286
+
287
+ if @executor.last_cancelled?
288
+ @history << { role: :user, content: "User declined to execute the code." }
289
+ :cancelled
290
+ elsif @executor.last_safety_error
291
+ exec_result = @executor.offer_danger_retry(code)
292
+ if exec_result || !@executor.last_error
293
+ @last_interactive_code = code
294
+ @last_interactive_output = @executor.last_output
295
+ @last_interactive_result = exec_result ? exec_result.inspect : nil
296
+ @last_interactive_executed = true
297
+
298
+ output_parts = []
299
+ if @executor.last_output && !@executor.last_output.strip.empty?
300
+ output_parts << "Output:\n#{@executor.last_output.strip}"
301
+ end
302
+ output_parts << "Return value: #{exec_result.inspect}" if exec_result
303
+ unless output_parts.empty?
304
+ result_str = output_parts.join("\n\n")
305
+ result_str = result_str[0..1000] + '...' if result_str.length > 1000
306
+ output_id = @executor.store_output(result_str)
307
+ @history << { role: :user, content: "Code was executed (safety override). #{result_str}", output_id: output_id }
308
+ end
309
+ :success
310
+ else
311
+ @history << { role: :user, content: "User declined to execute with safe mode disabled." }
312
+ :cancelled
313
+ end
314
+ elsif @executor.last_error
315
+ error_msg = "Code execution failed with error: #{@executor.last_error}"
316
+ error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
317
+ @history << { role: :user, content: error_msg }
318
+ :error
319
+ else
320
+ output_parts = []
321
+ if @executor.last_output && !@executor.last_output.strip.empty?
322
+ output_parts << "Output:\n#{@executor.last_output.strip}"
323
+ end
324
+ output_parts << "Return value: #{exec_result.inspect}" if exec_result
325
+
326
+ unless output_parts.empty?
327
+ result_str = output_parts.join("\n\n")
328
+ result_str = result_str[0..1000] + '...' if result_str.length > 1000
329
+ output_id = @executor.store_output(result_str)
330
+ @history << { role: :user, content: "Code was executed. #{result_str}", output_id: output_id }
331
+ end
332
+
333
+ :success
334
+ end
335
+ end
336
+
337
+ # --- Display helpers (used by Channel::Console slash commands) ---
338
+
339
+ def display_session_summary
340
+ return if @total_input_tokens == 0 && @total_output_tokens == 0
341
+ $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
342
+ end
343
+
344
+ def display_cost_summary
345
+ if @token_usage.empty?
346
+ $stdout.puts "\e[2m No usage yet.\e[0m"
347
+ return
348
+ end
349
+
350
+ total_cost = 0.0
351
+ $stdout.puts "\e[36m Cost estimate:\e[0m"
352
+
353
+ @token_usage.each do |model, usage|
354
+ pricing = Configuration::PRICING[model]
355
+ pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAI.configuration.provider == :local
356
+ input_str = "in: #{format_tokens(usage[:input])}"
357
+ output_str = "out: #{format_tokens(usage[:output])}"
358
+
359
+ if pricing
360
+ cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
361
+ cache_read = usage[:cache_read] || 0
362
+ cache_write = usage[:cache_write] || 0
363
+ if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
364
+ # Subtract cached tokens from full-price input, add at cache rates
365
+ cost -= cache_read * pricing[:input]
366
+ cost += cache_read * pricing[:cache_read]
367
+ cost += cache_write * (pricing[:cache_write] - pricing[:input])
368
+ end
369
+ total_cost += cost
370
+ cache_str = ""
371
+ cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
372
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}\e[0m"
373
+ else
374
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} (pricing unknown)\e[0m"
375
+ end
376
+ end
377
+
378
+ $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
379
+ end
380
+
381
+ def display_conversation
382
+ stdout = @channel.respond_to?(:real_stdout) ? @channel.real_stdout : $stdout
383
+ if @history.empty?
384
+ stdout.puts "\e[2m (no conversation history yet)\e[0m"
385
+ return
386
+ end
387
+
388
+ trimmed = trim_old_outputs(@history)
389
+ stdout.puts "\e[36m Conversation (#{trimmed.length} messages, as sent to LLM):\e[0m"
390
+ trimmed.each_with_index do |msg, i|
391
+ role = msg[:role].to_s
392
+ content = msg[:content].to_s
393
+ label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
394
+ stdout.puts "#{label} #{content}"
395
+ stdout.puts if i < trimmed.length - 1
396
+ end
397
+ end
398
+
399
+ def context
400
+ base = @context_base ||= context_builder.build
401
+ parts = [base]
402
+ parts << safety_context
403
+ parts << @channel.system_instructions
404
+ parts << binding_variable_summary
405
+ parts.compact.join("\n\n")
406
+ end
407
+
408
+ def upgrade_to_thinking_model
409
+ config = RailsConsoleAI.configuration
410
+ current = config.resolved_model
411
+ thinking = config.resolved_thinking_model
412
+
413
+ if current == thinking
414
+ $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
415
+ else
416
+ config.model = thinking
417
+ @provider = nil
418
+ $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
419
+ end
420
+ end
421
+
422
+ def compact_history
423
+ if @history.length < 6
424
+ $stdout.puts "\e[33m History too short to compact (#{@history.length} messages). Need at least 6.\e[0m"
425
+ return
426
+ end
427
+
428
+ before_chars = @history.sum { |m| m[:content].to_s.length }
429
+ before_count = @history.length
430
+
431
+ executed_code = extract_executed_code(@history)
432
+
433
+ $stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
434
+
435
+ system_prompt = <<~PROMPT
436
+ You are a conversation summarizer. The user will provide a conversation history from a Rails console AI assistant session.
437
+
438
+ Produce a concise summary that captures:
439
+ - What the user has been working on and their goals
440
+ - Key findings and data discovered (include specific values, IDs, record counts)
441
+ - Current state: what worked, what failed, where things stand
442
+ - Important variable names, model names, or table names referenced
443
+
444
+ Do NOT include code that was executed — that will be preserved separately.
445
+ Be concise but preserve all information that would be needed to continue the conversation naturally.
446
+ Do NOT include any preamble — just output the summary directly.
447
+ PROMPT
448
+
449
+ history_text = @history.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
450
+ messages = [{ role: :user, content: "Summarize this conversation history:\n\n#{history_text}" }]
451
+
452
+ begin
453
+ result = provider.chat(messages, system_prompt: system_prompt)
454
+ track_usage(result)
455
+
456
+ summary = result.text.to_s.strip
457
+ if summary.empty?
458
+ $stdout.puts "\e[33m Compaction failed: empty summary returned.\e[0m"
459
+ return
460
+ end
461
+
462
+ content = "CONVERSATION SUMMARY (compacted):\n#{summary}"
463
+ unless executed_code.empty?
464
+ content += "\n\nCODE EXECUTED THIS SESSION (preserved for continuation):\n#{executed_code}"
465
+ end
466
+
467
+ @history = [{ role: :user, content: content }]
468
+ @compact_warned = false
469
+
470
+ after_chars = @history.first[:content].length
471
+ $stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
472
+ summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
473
+ if !executed_code.empty?
474
+ $stdout.puts "\e[2m (preserved #{executed_code.scan(/```ruby/).length} executed code block(s))\e[0m"
475
+ end
476
+ display_usage(result)
477
+ rescue => e
478
+ $stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
479
+ end
480
+ end
481
+
482
+ def warn_if_history_large
483
+ chars = @history.sum { |m| m[:content].to_s.length }
484
+
485
+ if chars > 50_000 && !@compact_warned
486
+ @compact_warned = true
487
+ $stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
488
+ end
489
+ end
490
+
491
+ # --- Session logging ---
492
+
493
+ def log_interactive_turn
494
+ require 'rails_console_ai/session_logger'
495
+ session_attrs = {
496
+ conversation: @history,
497
+ input_tokens: @total_input_tokens,
498
+ output_tokens: @total_output_tokens,
499
+ code_executed: @last_interactive_code,
500
+ code_output: @last_interactive_output,
501
+ code_result: @last_interactive_result,
502
+ executed: @last_interactive_executed,
503
+ console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil
504
+ }
505
+
506
+ if @interactive_session_id
507
+ SessionLogger.update(@interactive_session_id, session_attrs)
508
+ else
509
+ log_attrs = session_attrs.merge(
510
+ query: @interactive_query || '(interactive session)',
511
+ mode: @slack_thread_ts ? 'slack' : 'interactive',
512
+ name: @session_name
513
+ )
514
+ log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
515
+ if @channel.user_identity
516
+ log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
517
+ end
518
+ @interactive_session_id = SessionLogger.log(log_attrs)
519
+ end
520
+ end
521
+
522
+ def finish_interactive_session
523
+ @executor.on_prompt = nil
524
+ require 'rails_console_ai/session_logger'
525
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @interactive_start) * 1000).round + @prior_duration_ms
526
+ if @interactive_session_id
527
+ SessionLogger.update(@interactive_session_id,
528
+ conversation: @history,
529
+ input_tokens: @total_input_tokens,
530
+ output_tokens: @total_output_tokens,
531
+ code_executed: @last_interactive_code,
532
+ code_output: @last_interactive_output,
533
+ code_result: @last_interactive_result,
534
+ executed: @last_interactive_executed,
535
+ console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil,
536
+ duration_ms: duration_ms
537
+ )
538
+ elsif @interactive_query
539
+ log_attrs = {
540
+ query: @interactive_query,
541
+ conversation: @history,
542
+ mode: @slack_thread_ts ? 'slack' : 'interactive',
543
+ code_executed: @last_interactive_code,
544
+ code_output: @last_interactive_output,
545
+ code_result: @last_interactive_result,
546
+ executed: @last_interactive_executed,
547
+ console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil,
548
+ start_time: @interactive_start
549
+ }
550
+ log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
551
+ if @channel.user_identity
552
+ log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
553
+ end
554
+ log_session(log_attrs)
555
+ end
556
+ end
557
+
558
+ private
559
+
560
+ def safety_context
561
+ guards = RailsConsoleAI.configuration.safety_guards
562
+ return nil if guards.empty?
563
+
564
+ if !@channel.supports_danger?
565
+ <<~PROMPT.strip
566
+ ## Safety Guards (ENFORCED — CANNOT BE DISABLED)
567
+
568
+ This session has safety guards that block side effects. These guards CANNOT be bypassed,
569
+ disabled, or worked around in this channel. Do NOT attempt to:
570
+ - Search for ways to disable safety guards
571
+ - Look for SafetyError, allow_writes, or similar bypass mechanisms
572
+ - Suggest the user disable protections
573
+ - Re-attempt blocked operations with different syntax
574
+
575
+ When an operation is blocked, report what happened and move on.
576
+ Only read operations are permitted.
577
+ PROMPT
578
+ elsif guards.enabled?
579
+ <<~PROMPT.strip
580
+ ## Safety Guards
581
+
582
+ This session has safety guards that block side effects (database writes, HTTP mutations, etc.).
583
+ If an operation is blocked, the user will be prompted to allow it or disable guards.
584
+ PROMPT
585
+ end
586
+ end
587
+
588
+ def one_shot_round(conversation)
589
+ result, _ = send_query(nil, conversation: conversation)
590
+ track_usage(result)
591
+ code = @executor.display_response(result.text)
592
+ display_usage(result)
593
+ @_last_result_text = result.text
594
+
595
+ exec_result = nil
596
+ executed = false
597
+ has_code = code && !code.strip.empty?
598
+
599
+ if has_code
600
+ exec_result = if RailsConsoleAI.configuration.auto_execute
601
+ @executor.execute(code)
602
+ else
603
+ @executor.confirm_and_execute(code)
604
+ end
605
+ executed = !@executor.last_cancelled?
606
+ end
607
+
608
+ [exec_result, has_code ? code : nil, executed]
609
+ end
610
+
611
+ def provider
612
+ @provider ||= Providers.build
613
+ end
614
+
615
+ def context_builder
616
+ @context_builder ||= ContextBuilder.new
617
+ end
618
+
619
+ def binding_variable_summary
620
+ parts = []
621
+
622
+ locals = @binding_context.local_variables.reject { |v| v.to_s.start_with?('_') }
623
+ locals.first(20).each do |var|
624
+ val = @binding_context.local_variable_get(var) rescue nil
625
+ parts << "#{var} (#{val.class})"
626
+ end
627
+
628
+ ivars = (@binding_context.eval("instance_variables") rescue [])
629
+ ivars.reject { |v| v.to_s =~ /\A@_/ }.first(20).each do |var|
630
+ val = @binding_context.eval(var.to_s) rescue nil
631
+ parts << "#{var} (#{val.class})"
632
+ end
633
+
634
+ return nil if parts.empty?
635
+ "The user's console session has these variables available: #{parts.join(', ')}. You can reference them directly in code."
636
+ rescue
637
+ nil
638
+ end
639
+
640
+ def init_system_prompt(existing_guide)
641
+ env = context_builder.environment_context
642
+
643
+ prompt = <<~PROMPT
644
+ You are a Rails application analyst. Your job is to explore this Rails app using the
645
+ available tools and produce a concise markdown guide that will be injected into future
646
+ AI assistant sessions.
647
+
648
+ #{env}
649
+
650
+ EXPLORATION STRATEGY — be efficient to avoid timeouts:
651
+ 1. Start with list_models to see all models and their associations
652
+ 2. Pick the 5-8 CORE models and call describe_model on those only
653
+ 3. Call describe_table on only 3-5 key tables (skip tables whose models already told you enough)
654
+ 4. Use search_code sparingly — only for specific patterns you suspect (sharding, STI, concerns)
655
+ 5. Use read_file only when you need to understand a specific pattern (read small sections, not whole files)
656
+ 6. Do NOT exhaustively describe every table or model — focus on what's important
657
+
658
+ IMPORTANT: Keep your total tool calls under 20. Prioritize breadth over depth.
659
+
660
+ Produce a markdown document with these sections:
661
+ - **Application Overview**: What the app does, key domain concepts
662
+ - **Key Models & Relationships**: Core models and how they relate
663
+ - **Data Architecture**: Important tables, notable columns, any partitioning/sharding
664
+ - **Important Patterns**: Custom concerns, service objects, key abstractions
665
+ - **Common Maintenance Tasks**: Typical console operations for this app
666
+ - **Gotchas**: Non-obvious behaviors, tricky associations, known quirks
667
+
668
+ Keep it concise — aim for 1-2 pages. Focus on what a console user needs to know.
669
+ Do NOT wrap the output in markdown code fences.
670
+ PROMPT
671
+
672
+ if existing_guide
673
+ prompt += <<~UPDATE
674
+
675
+ Here is the existing guide. Update and merge with any new findings:
676
+
677
+ #{existing_guide}
678
+ UPDATE
679
+ end
680
+
681
+ prompt.strip
682
+ end
683
+
684
+ def send_query(query, conversation: nil)
685
+ RailsConsoleAI.configuration.validate!
686
+
687
+ messages = if conversation
688
+ conversation.dup
689
+ else
690
+ [{ role: :user, content: query }]
691
+ end
692
+
693
+ messages = trim_old_outputs(messages) if conversation
694
+
695
+ send_query_with_tools(messages)
696
+ end
697
+
698
+ def send_query_with_tools(messages, system_prompt: nil, tools_override: nil)
699
+ require 'rails_console_ai/tools/registry'
700
+ tools = tools_override || Tools::Registry.new(executor: @executor, channel: @channel)
701
+ active_system_prompt = system_prompt || context
702
+ max_rounds = RailsConsoleAI.configuration.max_tool_rounds
703
+ total_input = 0
704
+ total_output = 0
705
+ result = nil
706
+ new_messages = []
707
+ last_thinking = nil
708
+ last_tool_names = []
709
+
710
+ exhausted = false
711
+
712
+ max_rounds.times do |round|
713
+ if @channel.cancelled?
714
+ @channel.display_dim(" Cancelled.")
715
+ break
716
+ end
717
+
718
+ if round == 0
719
+ @channel.display_dim(" Thinking...")
720
+ else
721
+ if last_thinking
722
+ last_thinking.split("\n").each do |line|
723
+ @channel.display_dim(" #{line}")
724
+ end
725
+ end
726
+ @channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
727
+ end
728
+
729
+ if RailsConsoleAI.configuration.debug
730
+ debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
731
+ end
732
+
733
+ begin
734
+ result = @channel.wrap_llm_call do
735
+ provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
736
+ end
737
+ rescue Providers::ProviderError => e
738
+ raise
739
+ end
740
+ total_input += result.input_tokens || 0
741
+ total_output += result.output_tokens || 0
742
+
743
+ break if @channel.cancelled?
744
+
745
+ if RailsConsoleAI.configuration.debug
746
+ debug_post_call(round, result, @total_input_tokens + total_input, @total_output_tokens + total_output)
747
+ end
748
+
749
+ break unless result.tool_use?
750
+
751
+ last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
752
+
753
+ assistant_msg = provider.format_assistant_message(result)
754
+ messages << assistant_msg
755
+ new_messages << assistant_msg
756
+
757
+ last_tool_names = result.tool_calls.map { |tc| tc[:name] }
758
+ result.tool_calls.each do |tc|
759
+ break if @channel.cancelled?
760
+ if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
761
+ tool_result = tools.execute(tc[:name], tc[:arguments])
762
+ else
763
+ args_display = format_tool_args(tc[:name], tc[:arguments])
764
+ $stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
765
+
766
+ tool_result = tools.execute(tc[:name], tc[:arguments])
767
+
768
+ preview = compact_tool_result(tc[:name], tool_result)
769
+ cached_tag = tools.last_cached? ? " (cached)" : ""
770
+ @channel.display_dim(" #{preview}#{cached_tag}")
771
+ end
772
+
773
+ if RailsConsoleAI.configuration.debug
774
+ $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
775
+ end
776
+
777
+ tool_msg = provider.format_tool_result(tc[:id], tool_result)
778
+ if tool_result.to_s.length > 200
779
+ tool_msg[:output_id] = @executor.store_output(tool_result.to_s)
780
+ end
781
+ messages << tool_msg
782
+ new_messages << tool_msg
783
+ end
784
+
785
+ exhausted = true if round == max_rounds - 1
786
+ end
787
+
788
+ if exhausted
789
+ $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAI.configure { |c| c.max_tool_rounds = 200 }\e[0m"
790
+ messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
791
+ result = provider.chat(messages, system_prompt: active_system_prompt)
792
+ total_input += result.input_tokens || 0
793
+ total_output += result.output_tokens || 0
794
+ end
795
+
796
+ final_result = Providers::ChatResult.new(
797
+ text: result ? result.text : '',
798
+ input_tokens: total_input,
799
+ output_tokens: total_output,
800
+ stop_reason: result ? result.stop_reason : :end_turn
801
+ )
802
+ [final_result, new_messages]
803
+ end
804
+
805
+ def track_usage(result)
806
+ @total_input_tokens += result.input_tokens || 0
807
+ @total_output_tokens += result.output_tokens || 0
808
+
809
+ model = RailsConsoleAI.configuration.resolved_model
810
+ @token_usage[model][:input] += result.input_tokens || 0
811
+ @token_usage[model][:output] += result.output_tokens || 0
812
+ @token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
813
+ @token_usage[model][:cache_write] = (@token_usage[model][:cache_write] || 0) + (result.cache_write_input_tokens || 0)
814
+ end
815
+
816
+ def display_usage(result, show_session: false)
817
+ input = result.input_tokens
818
+ output = result.output_tokens
819
+ return unless input || output
820
+
821
+ parts = []
822
+ parts << "in: #{input}" if input
823
+ parts << "out: #{output}" if output
824
+ parts << "total: #{result.total_tokens}"
825
+
826
+ line = "\e[2m[tokens #{parts.join(' | ')}]\e[0m"
827
+
828
+ if show_session && (@total_input_tokens + @total_output_tokens) > result.total_tokens
829
+ line += "\e[2m [session: in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
830
+ end
831
+
832
+ $stdout.puts line
833
+ end
834
+
835
+ def with_console_capture(capture_io)
836
+ old_stdout = $stdout
837
+ $stdout = TeeIO.new(old_stdout, capture_io)
838
+ yield
839
+ ensure
840
+ $stdout = old_stdout
841
+ end
842
+
843
+ def log_session(attrs)
844
+ require 'rails_console_ai/session_logger'
845
+ start_time = attrs.delete(:start_time)
846
+ duration_ms = if start_time
847
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
848
+ end
849
+ SessionLogger.log(
850
+ attrs.merge(
851
+ input_tokens: @total_input_tokens,
852
+ output_tokens: @total_output_tokens,
853
+ duration_ms: duration_ms
854
+ )
855
+ )
856
+ end
857
+
858
+ # --- Formatting helpers ---
859
+
860
+ def format_tokens(count)
861
+ if count >= 1_000_000
862
+ "#{(count / 1_000_000.0).round(1)}M"
863
+ elsif count >= 1_000
864
+ "#{(count / 1_000.0).round(1)}K"
865
+ else
866
+ count.to_s
867
+ end
868
+ end
869
+
870
+ def format_tool_args(name, args)
871
+ return '' if args.nil? || args.empty?
872
+
873
+ case name
874
+ when 'describe_table' then "(\"#{args['table_name']}\")"
875
+ when 'describe_model' then "(\"#{args['model_name']}\")"
876
+ when 'read_file' then "(\"#{args['path']}\")"
877
+ when 'search_code'
878
+ dir = args['directory'] ? ", dir: \"#{args['directory']}\"" : ''
879
+ "(\"#{args['query']}\"#{dir})"
880
+ when 'list_files' then args['directory'] ? "(\"#{args['directory']}\")" : ''
881
+ when 'save_memory' then "(\"#{args['name']}\")"
882
+ when 'delete_memory' then "(\"#{args['name']}\")"
883
+ when 'recall_memories' then args['query'] ? "(\"#{args['query']}\")" : ''
884
+ when 'execute_plan'
885
+ steps = args['steps']
886
+ steps ? "(#{steps.length} steps)" : ''
887
+ else ''
888
+ end
889
+ end
890
+
891
+ def compact_tool_result(name, result)
892
+ return '(empty)' if result.nil? || result.strip.empty?
893
+
894
+ case name
895
+ when 'list_tables'
896
+ tables = result.split(', ')
897
+ tables.length > 8 ? "#{tables.length} tables: #{tables.first(8).join(', ')}..." : "#{tables.length} tables: #{result}"
898
+ when 'list_models'
899
+ lines = result.split("\n")
900
+ lines.length > 6 ? "#{lines.length} models: #{lines.first(6).map { |l| l.split(' ').first }.join(', ')}..." : "#{lines.length} models"
901
+ when 'describe_table'
902
+ "#{result.scan(/^\s{2}\S/).length} columns"
903
+ when 'describe_model'
904
+ parts = []
905
+ assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
906
+ val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
907
+ parts << "#{assoc_count} associations" if assoc_count > 0
908
+ parts << "#{val_count} validations" if val_count > 0
909
+ parts.empty? ? truncate(result, 80) : parts.join(', ')
910
+ when 'list_files' then "#{result.split("\n").length} files"
911
+ when 'read_file'
912
+ if result =~ /^Lines (\d+)-(\d+) of (\d+):/
913
+ "lines #{$1}-#{$2} of #{$3}"
914
+ else
915
+ "#{result.split("\n").length} lines"
916
+ end
917
+ when 'search_code'
918
+ if result.start_with?('Found') then result.split("\n").first
919
+ elsif result.start_with?('No matches') then result
920
+ else truncate(result, 80)
921
+ end
922
+ when 'save_memory'
923
+ (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
924
+ when 'delete_memory'
925
+ result.start_with?('Memory deleted') ? result : truncate(result, 80)
926
+ when 'recall_memories'
927
+ chunks = result.split("\n\n")
928
+ chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
929
+ when 'execute_plan'
930
+ steps_done = result.scan(/^Step \d+/).length
931
+ steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
932
+ else
933
+ truncate(result, 80)
934
+ end
935
+ end
936
+
937
+ def truncate(str, max)
938
+ str.length > max ? str[0..max] + '...' : str
939
+ end
940
+
941
+ def llm_status(round, messages, tokens_so_far, last_thinking = nil, last_tool_names = [])
942
+ status = "Calling LLM (round #{round + 1}, #{messages.length} msgs"
943
+ status += ", ~#{format_tokens(tokens_so_far)} ctx" if tokens_so_far > 0
944
+ status += ")"
945
+ if !last_thinking && last_tool_names.any?
946
+ counts = last_tool_names.tally
947
+ summary = counts.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")
948
+ status += " after #{summary}"
949
+ end
950
+ status += "..."
951
+ status
952
+ end
953
+
954
+ def debug_pre_call(round, messages, system_prompt, tools, total_input, total_output)
955
+ d = "\e[35m"
956
+ r = "\e[0m"
957
+
958
+ user_msgs = 0; assistant_msgs = 0; tool_result_msgs = 0; tool_use_msgs = 0
959
+ output_msgs = 0; omitted_msgs = 0
960
+ total_content_chars = system_prompt.to_s.length
961
+
962
+ messages.each do |msg|
963
+ content_str = msg[:content].is_a?(Array) ? msg[:content].to_s : msg[:content].to_s
964
+ total_content_chars += content_str.length
965
+
966
+ role = msg[:role].to_s
967
+ if role == 'tool'
968
+ tool_result_msgs += 1
969
+ elsif msg[:content].is_a?(Array)
970
+ msg[:content].each do |block|
971
+ next unless block.is_a?(Hash)
972
+ if block['type'] == 'tool_result'
973
+ tool_result_msgs += 1
974
+ omitted_msgs += 1 if block['content'].to_s.include?('Output omitted')
975
+ elsif block['type'] == 'tool_use'
976
+ tool_use_msgs += 1
977
+ end
978
+ end
979
+ elsif role == 'user'
980
+ user_msgs += 1
981
+ if content_str.include?('Code was executed') || content_str.include?('directly executed code')
982
+ output_msgs += 1
983
+ omitted_msgs += 1 if content_str.include?('Output omitted')
984
+ end
985
+ elsif role == 'assistant'
986
+ assistant_msgs += 1
987
+ end
988
+ end
989
+
990
+ tool_count = tools.respond_to?(:definitions) ? tools.definitions.length : 0
991
+
992
+ $stderr.puts "#{d}[debug] ── LLM call ##{round + 1} ──#{r}"
993
+ $stderr.puts "#{d}[debug] system prompt: #{format_tokens(system_prompt.to_s.length)} chars#{r}"
994
+ $stderr.puts "#{d}[debug] messages: #{messages.length} (#{user_msgs} user, #{assistant_msgs} assistant, #{tool_result_msgs} tool results, #{tool_use_msgs} tool calls)#{r}"
995
+ $stderr.puts "#{d}[debug] execution outputs: #{output_msgs} (#{omitted_msgs} omitted)#{r}" if output_msgs > 0 || omitted_msgs > 0
996
+ $stderr.puts "#{d}[debug] tools provided: #{tool_count}#{r}"
997
+ $stderr.puts "#{d}[debug] est. content size: #{format_tokens(total_content_chars)} chars#{r}"
998
+ if total_input > 0 || total_output > 0
999
+ $stderr.puts "#{d}[debug] tokens so far: in: #{format_tokens(total_input)} | out: #{format_tokens(total_output)}#{r}"
1000
+ end
1001
+ end
1002
+
1003
+ def debug_post_call(round, result, total_input, total_output)
1004
+ d = "\e[35m"
1005
+ r = "\e[0m"
1006
+
1007
+ input_t = result.input_tokens || 0
1008
+ output_t = result.output_tokens || 0
1009
+ model = RailsConsoleAI.configuration.resolved_model
1010
+ pricing = Configuration::PRICING[model]
1011
+ pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAI.configuration.provider == :local
1012
+
1013
+ cache_r = result.cache_read_input_tokens || 0
1014
+ cache_w = result.cache_write_input_tokens || 0
1015
+ parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
1016
+ parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1017
+
1018
+ if pricing
1019
+ cost = (input_t * pricing[:input]) + (output_t * pricing[:output])
1020
+ if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1021
+ cost -= cache_r * pricing[:input]
1022
+ cost += cache_r * pricing[:cache_read]
1023
+ cost += cache_w * (pricing[:cache_write] - pricing[:input])
1024
+ end
1025
+ session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1026
+ parts << "~$#{'%.4f' % cost}"
1027
+ $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1028
+ else
1029
+ $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
1030
+ end
1031
+
1032
+ if result.tool_use?
1033
+ tool_names = result.tool_calls.map { |tc| tc[:name] }
1034
+ $stderr.puts "#{d}[debug] tool calls: #{tool_names.join(', ')}#{r}"
1035
+ else
1036
+ $stderr.puts "#{d}[debug] stop reason: #{result.stop_reason}#{r}"
1037
+ end
1038
+ end
1039
+
1040
+ # --- Conversation context management ---
1041
+
1042
+ def trim_old_outputs(messages)
1043
+ output_indices = messages.each_with_index
1044
+ .select { |m, _| m[:output_id] }
1045
+ .map { |_, i| i }
1046
+
1047
+ if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
1048
+ return messages.map { |m| m.except(:output_id) }
1049
+ end
1050
+
1051
+ trim_indices = output_indices[0..-(RECENT_OUTPUTS_TO_KEEP + 1)]
1052
+ messages.each_with_index.map do |msg, i|
1053
+ if trim_indices.include?(i)
1054
+ trim_message(msg)
1055
+ else
1056
+ msg.except(:output_id)
1057
+ end
1058
+ end
1059
+ end
1060
+
1061
+ def trim_message(msg)
1062
+ ref = "[Output omitted — use recall_output tool with id #{msg[:output_id]} to retrieve]"
1063
+
1064
+ if msg[:content].is_a?(Array)
1065
+ trimmed_content = msg[:content].map do |block|
1066
+ if block.is_a?(Hash) && block['type'] == 'tool_result'
1067
+ block.merge('content' => ref)
1068
+ else
1069
+ block
1070
+ end
1071
+ end
1072
+ { role: msg[:role], content: trimmed_content }
1073
+ elsif msg[:role].to_s == 'tool'
1074
+ msg.except(:output_id).merge(content: ref)
1075
+ else
1076
+ first_line = msg[:content].to_s.lines.first&.strip || msg[:content]
1077
+ { role: msg[:role], content: "#{first_line}\n#{ref}" }
1078
+ end
1079
+ end
1080
+
1081
+ def extract_executed_code(history)
1082
+ code_blocks = []
1083
+ history.each_cons(2) do |msg, next_msg|
1084
+ if msg[:role].to_s == 'assistant' && next_msg[:role].to_s == 'user'
1085
+ content = msg[:content].to_s
1086
+ next_content = next_msg[:content].to_s
1087
+
1088
+ if next_content.start_with?('Code was executed.')
1089
+ content.scan(/```ruby\s*\n(.*?)```/m).each do |match|
1090
+ code = match[0].strip
1091
+ next if code.empty?
1092
+ result_summary = next_content[0..200].gsub("\n", "\n# ")
1093
+ code_blocks << "```ruby\n#{code}\n```\n# #{result_summary}"
1094
+ end
1095
+ end
1096
+ end
1097
+
1098
+ if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
1099
+ msg[:content].each do |block|
1100
+ next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
1101
+ input = block['input'] || {}
1102
+ steps = input['steps'] || []
1103
+
1104
+ tool_id = block['id']
1105
+ result_msg = find_tool_result(history, tool_id)
1106
+ next unless result_msg
1107
+
1108
+ result_text = result_msg.to_s
1109
+ steps.each_with_index do |step, i|
1110
+ step_num = i + 1
1111
+ step_section = result_text[/Step #{step_num}\b.*?(?=Step #{step_num + 1}\b|\z)/m] || ''
1112
+ next if step_section.include?('ERROR:')
1113
+ next if step_section.include?('User declined')
1114
+
1115
+ code = step['code'].to_s.strip
1116
+ next if code.empty?
1117
+ desc = step['description'] || "Step #{step_num}"
1118
+ code_blocks << "```ruby\n# #{desc}\n#{code}\n```"
1119
+ end
1120
+ end
1121
+ end
1122
+ end
1123
+ code_blocks.join("\n\n")
1124
+ end
1125
+
1126
+ def find_tool_result(history, tool_id)
1127
+ history.each do |msg|
1128
+ next unless msg[:content].is_a?(Array)
1129
+ msg[:content].each do |block|
1130
+ next unless block.is_a?(Hash)
1131
+ if block['type'] == 'tool_result' && block['tool_use_id'] == tool_id
1132
+ return block['content']
1133
+ end
1134
+ if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1135
+ return msg[:content]
1136
+ end
1137
+ end
1138
+ end
1139
+ nil
1140
+ end
1141
+ end
1142
+ end