console_agent 0.10.0 → 0.12.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.
@@ -0,0 +1,1122 @@
1
+ module ConsoleAgent
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("ConsoleAgent Error: #{e.message}")
69
+ nil
70
+ rescue => e
71
+ @channel.display_error("ConsoleAgent 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("ConsoleAgent Error: #{e.message}")
97
+ nil
98
+ rescue => e
99
+ @channel.display_error("ConsoleAgent 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 = ConsoleAgent.storage
119
+ existing_guide = begin
120
+ content = storage.read(ConsoleAgent::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 'console_agent/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 = ConsoleAgent.configuration.timeout
138
+ ConsoleAgent.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(ConsoleAgent::GUIDE_KEY, guide_text)
152
+ path = storage.respond_to?(:root_path) ? File.join(storage.root_path, ConsoleAgent::GUIDE_KEY) : ConsoleAgent::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("ConsoleAgent Error: #{e.message}")
161
+ nil
162
+ rescue => e
163
+ @channel.display_error("ConsoleAgent Error: #{e.class}: #{e.message}")
164
+ nil
165
+ ensure
166
+ ConsoleAgent.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 'console_agent/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("ConsoleAgent 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 ConsoleAgent.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 ConsoleAgent.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
+ total_cost += cost
362
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} ~$#{'%.2f' % cost}\e[0m"
363
+ else
364
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} (pricing unknown)\e[0m"
365
+ end
366
+ end
367
+
368
+ $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
369
+ end
370
+
371
+ def display_conversation
372
+ stdout = @channel.respond_to?(:real_stdout) ? @channel.real_stdout : $stdout
373
+ if @history.empty?
374
+ stdout.puts "\e[2m (no conversation history yet)\e[0m"
375
+ return
376
+ end
377
+
378
+ trimmed = trim_old_outputs(@history)
379
+ stdout.puts "\e[36m Conversation (#{trimmed.length} messages, as sent to LLM):\e[0m"
380
+ trimmed.each_with_index do |msg, i|
381
+ role = msg[:role].to_s
382
+ content = msg[:content].to_s
383
+ label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
384
+ stdout.puts "#{label} #{content}"
385
+ stdout.puts if i < trimmed.length - 1
386
+ end
387
+ end
388
+
389
+ def context
390
+ base = @context_base ||= context_builder.build
391
+ parts = [base]
392
+ parts << safety_context
393
+ parts << @channel.system_instructions
394
+ parts << binding_variable_summary
395
+ parts.compact.join("\n\n")
396
+ end
397
+
398
+ def upgrade_to_thinking_model
399
+ config = ConsoleAgent.configuration
400
+ current = config.resolved_model
401
+ thinking = config.resolved_thinking_model
402
+
403
+ if current == thinking
404
+ $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
405
+ else
406
+ config.model = thinking
407
+ @provider = nil
408
+ $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
409
+ end
410
+ end
411
+
412
+ def compact_history
413
+ if @history.length < 6
414
+ $stdout.puts "\e[33m History too short to compact (#{@history.length} messages). Need at least 6.\e[0m"
415
+ return
416
+ end
417
+
418
+ before_chars = @history.sum { |m| m[:content].to_s.length }
419
+ before_count = @history.length
420
+
421
+ executed_code = extract_executed_code(@history)
422
+
423
+ $stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
424
+
425
+ system_prompt = <<~PROMPT
426
+ You are a conversation summarizer. The user will provide a conversation history from a Rails console AI assistant session.
427
+
428
+ Produce a concise summary that captures:
429
+ - What the user has been working on and their goals
430
+ - Key findings and data discovered (include specific values, IDs, record counts)
431
+ - Current state: what worked, what failed, where things stand
432
+ - Important variable names, model names, or table names referenced
433
+
434
+ Do NOT include code that was executed — that will be preserved separately.
435
+ Be concise but preserve all information that would be needed to continue the conversation naturally.
436
+ Do NOT include any preamble — just output the summary directly.
437
+ PROMPT
438
+
439
+ history_text = @history.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
440
+ messages = [{ role: :user, content: "Summarize this conversation history:\n\n#{history_text}" }]
441
+
442
+ begin
443
+ result = provider.chat(messages, system_prompt: system_prompt)
444
+ track_usage(result)
445
+
446
+ summary = result.text.to_s.strip
447
+ if summary.empty?
448
+ $stdout.puts "\e[33m Compaction failed: empty summary returned.\e[0m"
449
+ return
450
+ end
451
+
452
+ content = "CONVERSATION SUMMARY (compacted):\n#{summary}"
453
+ unless executed_code.empty?
454
+ content += "\n\nCODE EXECUTED THIS SESSION (preserved for continuation):\n#{executed_code}"
455
+ end
456
+
457
+ @history = [{ role: :user, content: content }]
458
+ @compact_warned = false
459
+
460
+ after_chars = @history.first[:content].length
461
+ $stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
462
+ summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
463
+ if !executed_code.empty?
464
+ $stdout.puts "\e[2m (preserved #{executed_code.scan(/```ruby/).length} executed code block(s))\e[0m"
465
+ end
466
+ display_usage(result)
467
+ rescue => e
468
+ $stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
469
+ end
470
+ end
471
+
472
+ def warn_if_history_large
473
+ chars = @history.sum { |m| m[:content].to_s.length }
474
+
475
+ if chars > 50_000 && !@compact_warned
476
+ @compact_warned = true
477
+ $stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
478
+ end
479
+ end
480
+
481
+ # --- Session logging ---
482
+
483
+ def log_interactive_turn
484
+ require 'console_agent/session_logger'
485
+ session_attrs = {
486
+ conversation: @history,
487
+ input_tokens: @total_input_tokens,
488
+ output_tokens: @total_output_tokens,
489
+ code_executed: @last_interactive_code,
490
+ code_output: @last_interactive_output,
491
+ code_result: @last_interactive_result,
492
+ executed: @last_interactive_executed,
493
+ console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil
494
+ }
495
+
496
+ if @interactive_session_id
497
+ SessionLogger.update(@interactive_session_id, session_attrs)
498
+ else
499
+ log_attrs = session_attrs.merge(
500
+ query: @interactive_query || '(interactive session)',
501
+ mode: @slack_thread_ts ? 'slack' : 'interactive',
502
+ name: @session_name
503
+ )
504
+ log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
505
+ if @channel.user_identity
506
+ log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
507
+ end
508
+ @interactive_session_id = SessionLogger.log(log_attrs)
509
+ end
510
+ end
511
+
512
+ def finish_interactive_session
513
+ @executor.on_prompt = nil
514
+ require 'console_agent/session_logger'
515
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @interactive_start) * 1000).round + @prior_duration_ms
516
+ if @interactive_session_id
517
+ SessionLogger.update(@interactive_session_id,
518
+ conversation: @history,
519
+ input_tokens: @total_input_tokens,
520
+ output_tokens: @total_output_tokens,
521
+ code_executed: @last_interactive_code,
522
+ code_output: @last_interactive_output,
523
+ code_result: @last_interactive_result,
524
+ executed: @last_interactive_executed,
525
+ console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil,
526
+ duration_ms: duration_ms
527
+ )
528
+ elsif @interactive_query
529
+ log_attrs = {
530
+ query: @interactive_query,
531
+ conversation: @history,
532
+ mode: @slack_thread_ts ? 'slack' : 'interactive',
533
+ code_executed: @last_interactive_code,
534
+ code_output: @last_interactive_output,
535
+ code_result: @last_interactive_result,
536
+ executed: @last_interactive_executed,
537
+ console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil,
538
+ start_time: @interactive_start
539
+ }
540
+ log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
541
+ if @channel.user_identity
542
+ log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
543
+ end
544
+ log_session(log_attrs)
545
+ end
546
+ end
547
+
548
+ private
549
+
550
+ def safety_context
551
+ guards = ConsoleAgent.configuration.safety_guards
552
+ return nil if guards.empty?
553
+
554
+ if !@channel.supports_danger?
555
+ <<~PROMPT.strip
556
+ ## Safety Guards (ENFORCED — CANNOT BE DISABLED)
557
+
558
+ This session has safety guards that block side effects. These guards CANNOT be bypassed,
559
+ disabled, or worked around in this channel. Do NOT attempt to:
560
+ - Search for ways to disable safety guards
561
+ - Look for SafetyError, allow_writes, or similar bypass mechanisms
562
+ - Suggest the user disable protections
563
+ - Re-attempt blocked operations with different syntax
564
+
565
+ When an operation is blocked, report what happened and move on.
566
+ Only read operations are permitted.
567
+ PROMPT
568
+ elsif guards.enabled?
569
+ <<~PROMPT.strip
570
+ ## Safety Guards
571
+
572
+ This session has safety guards that block side effects (database writes, HTTP mutations, etc.).
573
+ If an operation is blocked, the user will be prompted to allow it or disable guards.
574
+ PROMPT
575
+ end
576
+ end
577
+
578
+ def one_shot_round(conversation)
579
+ result, _ = send_query(nil, conversation: conversation)
580
+ track_usage(result)
581
+ code = @executor.display_response(result.text)
582
+ display_usage(result)
583
+ @_last_result_text = result.text
584
+
585
+ exec_result = nil
586
+ executed = false
587
+ has_code = code && !code.strip.empty?
588
+
589
+ if has_code
590
+ exec_result = if ConsoleAgent.configuration.auto_execute
591
+ @executor.execute(code)
592
+ else
593
+ @executor.confirm_and_execute(code)
594
+ end
595
+ executed = !@executor.last_cancelled?
596
+ end
597
+
598
+ [exec_result, has_code ? code : nil, executed]
599
+ end
600
+
601
+ def provider
602
+ @provider ||= Providers.build
603
+ end
604
+
605
+ def context_builder
606
+ @context_builder ||= ContextBuilder.new
607
+ end
608
+
609
+ def binding_variable_summary
610
+ parts = []
611
+
612
+ locals = @binding_context.local_variables.reject { |v| v.to_s.start_with?('_') }
613
+ locals.first(20).each do |var|
614
+ val = @binding_context.local_variable_get(var) rescue nil
615
+ parts << "#{var} (#{val.class})"
616
+ end
617
+
618
+ ivars = (@binding_context.eval("instance_variables") rescue [])
619
+ ivars.reject { |v| v.to_s =~ /\A@_/ }.first(20).each do |var|
620
+ val = @binding_context.eval(var.to_s) rescue nil
621
+ parts << "#{var} (#{val.class})"
622
+ end
623
+
624
+ return nil if parts.empty?
625
+ "The user's console session has these variables available: #{parts.join(', ')}. You can reference them directly in code."
626
+ rescue
627
+ nil
628
+ end
629
+
630
+ def init_system_prompt(existing_guide)
631
+ env = context_builder.environment_context
632
+
633
+ prompt = <<~PROMPT
634
+ You are a Rails application analyst. Your job is to explore this Rails app using the
635
+ available tools and produce a concise markdown guide that will be injected into future
636
+ AI assistant sessions.
637
+
638
+ #{env}
639
+
640
+ EXPLORATION STRATEGY — be efficient to avoid timeouts:
641
+ 1. Start with list_models to see all models and their associations
642
+ 2. Pick the 5-8 CORE models and call describe_model on those only
643
+ 3. Call describe_table on only 3-5 key tables (skip tables whose models already told you enough)
644
+ 4. Use search_code sparingly — only for specific patterns you suspect (sharding, STI, concerns)
645
+ 5. Use read_file only when you need to understand a specific pattern (read small sections, not whole files)
646
+ 6. Do NOT exhaustively describe every table or model — focus on what's important
647
+
648
+ IMPORTANT: Keep your total tool calls under 20. Prioritize breadth over depth.
649
+
650
+ Produce a markdown document with these sections:
651
+ - **Application Overview**: What the app does, key domain concepts
652
+ - **Key Models & Relationships**: Core models and how they relate
653
+ - **Data Architecture**: Important tables, notable columns, any partitioning/sharding
654
+ - **Important Patterns**: Custom concerns, service objects, key abstractions
655
+ - **Common Maintenance Tasks**: Typical console operations for this app
656
+ - **Gotchas**: Non-obvious behaviors, tricky associations, known quirks
657
+
658
+ Keep it concise — aim for 1-2 pages. Focus on what a console user needs to know.
659
+ Do NOT wrap the output in markdown code fences.
660
+ PROMPT
661
+
662
+ if existing_guide
663
+ prompt += <<~UPDATE
664
+
665
+ Here is the existing guide. Update and merge with any new findings:
666
+
667
+ #{existing_guide}
668
+ UPDATE
669
+ end
670
+
671
+ prompt.strip
672
+ end
673
+
674
+ def send_query(query, conversation: nil)
675
+ ConsoleAgent.configuration.validate!
676
+
677
+ messages = if conversation
678
+ conversation.dup
679
+ else
680
+ [{ role: :user, content: query }]
681
+ end
682
+
683
+ messages = trim_old_outputs(messages) if conversation
684
+
685
+ send_query_with_tools(messages)
686
+ end
687
+
688
+ def send_query_with_tools(messages, system_prompt: nil, tools_override: nil)
689
+ require 'console_agent/tools/registry'
690
+ tools = tools_override || Tools::Registry.new(executor: @executor, channel: @channel)
691
+ active_system_prompt = system_prompt || context
692
+ max_rounds = ConsoleAgent.configuration.max_tool_rounds
693
+ total_input = 0
694
+ total_output = 0
695
+ result = nil
696
+ new_messages = []
697
+ last_thinking = nil
698
+ last_tool_names = []
699
+
700
+ exhausted = false
701
+
702
+ max_rounds.times do |round|
703
+ if @channel.cancelled?
704
+ @channel.display_dim(" Cancelled.")
705
+ break
706
+ end
707
+
708
+ if round == 0
709
+ @channel.display_dim(" Thinking...")
710
+ else
711
+ if last_thinking
712
+ last_thinking.split("\n").each do |line|
713
+ @channel.display_dim(" #{line}")
714
+ end
715
+ end
716
+ @channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
717
+ end
718
+
719
+ if ConsoleAgent.configuration.debug
720
+ debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
721
+ end
722
+
723
+ begin
724
+ result = @channel.wrap_llm_call do
725
+ provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
726
+ end
727
+ rescue Providers::ProviderError => e
728
+ raise
729
+ end
730
+ total_input += result.input_tokens || 0
731
+ total_output += result.output_tokens || 0
732
+
733
+ break if @channel.cancelled?
734
+
735
+ if ConsoleAgent.configuration.debug
736
+ debug_post_call(round, result, @total_input_tokens + total_input, @total_output_tokens + total_output)
737
+ end
738
+
739
+ break unless result.tool_use?
740
+
741
+ last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
742
+
743
+ assistant_msg = provider.format_assistant_message(result)
744
+ messages << assistant_msg
745
+ new_messages << assistant_msg
746
+
747
+ last_tool_names = result.tool_calls.map { |tc| tc[:name] }
748
+ result.tool_calls.each do |tc|
749
+ break if @channel.cancelled?
750
+ if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
751
+ tool_result = tools.execute(tc[:name], tc[:arguments])
752
+ else
753
+ args_display = format_tool_args(tc[:name], tc[:arguments])
754
+ $stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
755
+
756
+ tool_result = tools.execute(tc[:name], tc[:arguments])
757
+
758
+ preview = compact_tool_result(tc[:name], tool_result)
759
+ cached_tag = tools.last_cached? ? " (cached)" : ""
760
+ @channel.display_dim(" #{preview}#{cached_tag}")
761
+ end
762
+
763
+ if ConsoleAgent.configuration.debug
764
+ $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
765
+ end
766
+
767
+ tool_msg = provider.format_tool_result(tc[:id], tool_result)
768
+ if tool_result.to_s.length > 200
769
+ tool_msg[:output_id] = @executor.store_output(tool_result.to_s)
770
+ end
771
+ messages << tool_msg
772
+ new_messages << tool_msg
773
+ end
774
+
775
+ exhausted = true if round == max_rounds - 1
776
+ end
777
+
778
+ if exhausted
779
+ $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: ConsoleAgent.configure { |c| c.max_tool_rounds = 200 }\e[0m"
780
+ 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." }
781
+ result = provider.chat(messages, system_prompt: active_system_prompt)
782
+ total_input += result.input_tokens || 0
783
+ total_output += result.output_tokens || 0
784
+ end
785
+
786
+ final_result = Providers::ChatResult.new(
787
+ text: result ? result.text : '',
788
+ input_tokens: total_input,
789
+ output_tokens: total_output,
790
+ stop_reason: result ? result.stop_reason : :end_turn
791
+ )
792
+ [final_result, new_messages]
793
+ end
794
+
795
+ def track_usage(result)
796
+ @total_input_tokens += result.input_tokens || 0
797
+ @total_output_tokens += result.output_tokens || 0
798
+
799
+ model = ConsoleAgent.configuration.resolved_model
800
+ @token_usage[model][:input] += result.input_tokens || 0
801
+ @token_usage[model][:output] += result.output_tokens || 0
802
+ end
803
+
804
+ def display_usage(result, show_session: false)
805
+ input = result.input_tokens
806
+ output = result.output_tokens
807
+ return unless input || output
808
+
809
+ parts = []
810
+ parts << "in: #{input}" if input
811
+ parts << "out: #{output}" if output
812
+ parts << "total: #{result.total_tokens}"
813
+
814
+ line = "\e[2m[tokens #{parts.join(' | ')}]\e[0m"
815
+
816
+ if show_session && (@total_input_tokens + @total_output_tokens) > result.total_tokens
817
+ line += "\e[2m [session: in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
818
+ end
819
+
820
+ $stdout.puts line
821
+ end
822
+
823
+ def with_console_capture(capture_io)
824
+ old_stdout = $stdout
825
+ $stdout = TeeIO.new(old_stdout, capture_io)
826
+ yield
827
+ ensure
828
+ $stdout = old_stdout
829
+ end
830
+
831
+ def log_session(attrs)
832
+ require 'console_agent/session_logger'
833
+ start_time = attrs.delete(:start_time)
834
+ duration_ms = if start_time
835
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
836
+ end
837
+ SessionLogger.log(
838
+ attrs.merge(
839
+ input_tokens: @total_input_tokens,
840
+ output_tokens: @total_output_tokens,
841
+ duration_ms: duration_ms
842
+ )
843
+ )
844
+ end
845
+
846
+ # --- Formatting helpers ---
847
+
848
+ def format_tokens(count)
849
+ if count >= 1_000_000
850
+ "#{(count / 1_000_000.0).round(1)}M"
851
+ elsif count >= 1_000
852
+ "#{(count / 1_000.0).round(1)}K"
853
+ else
854
+ count.to_s
855
+ end
856
+ end
857
+
858
+ def format_tool_args(name, args)
859
+ return '' if args.nil? || args.empty?
860
+
861
+ case name
862
+ when 'describe_table' then "(\"#{args['table_name']}\")"
863
+ when 'describe_model' then "(\"#{args['model_name']}\")"
864
+ when 'read_file' then "(\"#{args['path']}\")"
865
+ when 'search_code'
866
+ dir = args['directory'] ? ", dir: \"#{args['directory']}\"" : ''
867
+ "(\"#{args['query']}\"#{dir})"
868
+ when 'list_files' then args['directory'] ? "(\"#{args['directory']}\")" : ''
869
+ when 'save_memory' then "(\"#{args['name']}\")"
870
+ when 'delete_memory' then "(\"#{args['name']}\")"
871
+ when 'recall_memories' then args['query'] ? "(\"#{args['query']}\")" : ''
872
+ when 'execute_plan'
873
+ steps = args['steps']
874
+ steps ? "(#{steps.length} steps)" : ''
875
+ else ''
876
+ end
877
+ end
878
+
879
+ def compact_tool_result(name, result)
880
+ return '(empty)' if result.nil? || result.strip.empty?
881
+
882
+ case name
883
+ when 'list_tables'
884
+ tables = result.split(', ')
885
+ tables.length > 8 ? "#{tables.length} tables: #{tables.first(8).join(', ')}..." : "#{tables.length} tables: #{result}"
886
+ when 'list_models'
887
+ lines = result.split("\n")
888
+ lines.length > 6 ? "#{lines.length} models: #{lines.first(6).map { |l| l.split(' ').first }.join(', ')}..." : "#{lines.length} models"
889
+ when 'describe_table'
890
+ "#{result.scan(/^\s{2}\S/).length} columns"
891
+ when 'describe_model'
892
+ parts = []
893
+ assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
894
+ val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
895
+ parts << "#{assoc_count} associations" if assoc_count > 0
896
+ parts << "#{val_count} validations" if val_count > 0
897
+ parts.empty? ? truncate(result, 80) : parts.join(', ')
898
+ when 'list_files' then "#{result.split("\n").length} files"
899
+ when 'read_file'
900
+ if result =~ /^Lines (\d+)-(\d+) of (\d+):/
901
+ "lines #{$1}-#{$2} of #{$3}"
902
+ else
903
+ "#{result.split("\n").length} lines"
904
+ end
905
+ when 'search_code'
906
+ if result.start_with?('Found') then result.split("\n").first
907
+ elsif result.start_with?('No matches') then result
908
+ else truncate(result, 80)
909
+ end
910
+ when 'save_memory'
911
+ (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
912
+ when 'delete_memory'
913
+ result.start_with?('Memory deleted') ? result : truncate(result, 80)
914
+ when 'recall_memories'
915
+ chunks = result.split("\n\n")
916
+ chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
917
+ when 'execute_plan'
918
+ steps_done = result.scan(/^Step \d+/).length
919
+ steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
920
+ else
921
+ truncate(result, 80)
922
+ end
923
+ end
924
+
925
+ def truncate(str, max)
926
+ str.length > max ? str[0..max] + '...' : str
927
+ end
928
+
929
+ def llm_status(round, messages, tokens_so_far, last_thinking = nil, last_tool_names = [])
930
+ status = "Calling LLM (round #{round + 1}, #{messages.length} msgs"
931
+ status += ", ~#{format_tokens(tokens_so_far)} ctx" if tokens_so_far > 0
932
+ status += ")"
933
+ if !last_thinking && last_tool_names.any?
934
+ counts = last_tool_names.tally
935
+ summary = counts.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")
936
+ status += " after #{summary}"
937
+ end
938
+ status += "..."
939
+ status
940
+ end
941
+
942
+ def debug_pre_call(round, messages, system_prompt, tools, total_input, total_output)
943
+ d = "\e[35m"
944
+ r = "\e[0m"
945
+
946
+ user_msgs = 0; assistant_msgs = 0; tool_result_msgs = 0; tool_use_msgs = 0
947
+ output_msgs = 0; omitted_msgs = 0
948
+ total_content_chars = system_prompt.to_s.length
949
+
950
+ messages.each do |msg|
951
+ content_str = msg[:content].is_a?(Array) ? msg[:content].to_s : msg[:content].to_s
952
+ total_content_chars += content_str.length
953
+
954
+ role = msg[:role].to_s
955
+ if role == 'tool'
956
+ tool_result_msgs += 1
957
+ elsif msg[:content].is_a?(Array)
958
+ msg[:content].each do |block|
959
+ next unless block.is_a?(Hash)
960
+ if block['type'] == 'tool_result'
961
+ tool_result_msgs += 1
962
+ omitted_msgs += 1 if block['content'].to_s.include?('Output omitted')
963
+ elsif block['type'] == 'tool_use'
964
+ tool_use_msgs += 1
965
+ end
966
+ end
967
+ elsif role == 'user'
968
+ user_msgs += 1
969
+ if content_str.include?('Code was executed') || content_str.include?('directly executed code')
970
+ output_msgs += 1
971
+ omitted_msgs += 1 if content_str.include?('Output omitted')
972
+ end
973
+ elsif role == 'assistant'
974
+ assistant_msgs += 1
975
+ end
976
+ end
977
+
978
+ tool_count = tools.respond_to?(:definitions) ? tools.definitions.length : 0
979
+
980
+ $stderr.puts "#{d}[debug] ── LLM call ##{round + 1} ──#{r}"
981
+ $stderr.puts "#{d}[debug] system prompt: #{format_tokens(system_prompt.to_s.length)} chars#{r}"
982
+ $stderr.puts "#{d}[debug] messages: #{messages.length} (#{user_msgs} user, #{assistant_msgs} assistant, #{tool_result_msgs} tool results, #{tool_use_msgs} tool calls)#{r}"
983
+ $stderr.puts "#{d}[debug] execution outputs: #{output_msgs} (#{omitted_msgs} omitted)#{r}" if output_msgs > 0 || omitted_msgs > 0
984
+ $stderr.puts "#{d}[debug] tools provided: #{tool_count}#{r}"
985
+ $stderr.puts "#{d}[debug] est. content size: #{format_tokens(total_content_chars)} chars#{r}"
986
+ if total_input > 0 || total_output > 0
987
+ $stderr.puts "#{d}[debug] tokens so far: in: #{format_tokens(total_input)} | out: #{format_tokens(total_output)}#{r}"
988
+ end
989
+ end
990
+
991
+ def debug_post_call(round, result, total_input, total_output)
992
+ d = "\e[35m"
993
+ r = "\e[0m"
994
+
995
+ input_t = result.input_tokens || 0
996
+ output_t = result.output_tokens || 0
997
+ model = ConsoleAgent.configuration.resolved_model
998
+ pricing = Configuration::PRICING[model]
999
+ pricing ||= { input: 0.0, output: 0.0 } if ConsoleAgent.configuration.provider == :local
1000
+
1001
+ parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
1002
+
1003
+ if pricing
1004
+ cost = (input_t * pricing[:input]) + (output_t * pricing[:output])
1005
+ session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1006
+ parts << "~$#{'%.4f' % cost}"
1007
+ $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1008
+ else
1009
+ $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
1010
+ end
1011
+
1012
+ if result.tool_use?
1013
+ tool_names = result.tool_calls.map { |tc| tc[:name] }
1014
+ $stderr.puts "#{d}[debug] tool calls: #{tool_names.join(', ')}#{r}"
1015
+ else
1016
+ $stderr.puts "#{d}[debug] stop reason: #{result.stop_reason}#{r}"
1017
+ end
1018
+ end
1019
+
1020
+ # --- Conversation context management ---
1021
+
1022
+ def trim_old_outputs(messages)
1023
+ output_indices = messages.each_with_index
1024
+ .select { |m, _| m[:output_id] }
1025
+ .map { |_, i| i }
1026
+
1027
+ if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
1028
+ return messages.map { |m| m.except(:output_id) }
1029
+ end
1030
+
1031
+ trim_indices = output_indices[0..-(RECENT_OUTPUTS_TO_KEEP + 1)]
1032
+ messages.each_with_index.map do |msg, i|
1033
+ if trim_indices.include?(i)
1034
+ trim_message(msg)
1035
+ else
1036
+ msg.except(:output_id)
1037
+ end
1038
+ end
1039
+ end
1040
+
1041
+ def trim_message(msg)
1042
+ ref = "[Output omitted — use recall_output tool with id #{msg[:output_id]} to retrieve]"
1043
+
1044
+ if msg[:content].is_a?(Array)
1045
+ trimmed_content = msg[:content].map do |block|
1046
+ if block.is_a?(Hash) && block['type'] == 'tool_result'
1047
+ block.merge('content' => ref)
1048
+ else
1049
+ block
1050
+ end
1051
+ end
1052
+ { role: msg[:role], content: trimmed_content }
1053
+ elsif msg[:role].to_s == 'tool'
1054
+ msg.except(:output_id).merge(content: ref)
1055
+ else
1056
+ first_line = msg[:content].to_s.lines.first&.strip || msg[:content]
1057
+ { role: msg[:role], content: "#{first_line}\n#{ref}" }
1058
+ end
1059
+ end
1060
+
1061
+ def extract_executed_code(history)
1062
+ code_blocks = []
1063
+ history.each_cons(2) do |msg, next_msg|
1064
+ if msg[:role].to_s == 'assistant' && next_msg[:role].to_s == 'user'
1065
+ content = msg[:content].to_s
1066
+ next_content = next_msg[:content].to_s
1067
+
1068
+ if next_content.start_with?('Code was executed.')
1069
+ content.scan(/```ruby\s*\n(.*?)```/m).each do |match|
1070
+ code = match[0].strip
1071
+ next if code.empty?
1072
+ result_summary = next_content[0..200].gsub("\n", "\n# ")
1073
+ code_blocks << "```ruby\n#{code}\n```\n# #{result_summary}"
1074
+ end
1075
+ end
1076
+ end
1077
+
1078
+ if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
1079
+ msg[:content].each do |block|
1080
+ next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
1081
+ input = block['input'] || {}
1082
+ steps = input['steps'] || []
1083
+
1084
+ tool_id = block['id']
1085
+ result_msg = find_tool_result(history, tool_id)
1086
+ next unless result_msg
1087
+
1088
+ result_text = result_msg.to_s
1089
+ steps.each_with_index do |step, i|
1090
+ step_num = i + 1
1091
+ step_section = result_text[/Step #{step_num}\b.*?(?=Step #{step_num + 1}\b|\z)/m] || ''
1092
+ next if step_section.include?('ERROR:')
1093
+ next if step_section.include?('User declined')
1094
+
1095
+ code = step['code'].to_s.strip
1096
+ next if code.empty?
1097
+ desc = step['description'] || "Step #{step_num}"
1098
+ code_blocks << "```ruby\n# #{desc}\n#{code}\n```"
1099
+ end
1100
+ end
1101
+ end
1102
+ end
1103
+ code_blocks.join("\n\n")
1104
+ end
1105
+
1106
+ def find_tool_result(history, tool_id)
1107
+ history.each do |msg|
1108
+ next unless msg[:content].is_a?(Array)
1109
+ msg[:content].each do |block|
1110
+ next unless block.is_a?(Hash)
1111
+ if block['type'] == 'tool_result' && block['tool_use_id'] == tool_id
1112
+ return block['content']
1113
+ end
1114
+ if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1115
+ return msg[:content]
1116
+ end
1117
+ end
1118
+ end
1119
+ nil
1120
+ end
1121
+ end
1122
+ end