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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +95 -0
- data/LICENSE +21 -0
- data/README.md +328 -0
- data/app/controllers/rails_console_ai/application_controller.rb +28 -0
- data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
- data/app/models/rails_console_ai/session.rb +23 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
- data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
- data/config/routes.rb +4 -0
- data/lib/generators/rails_console_ai/install_generator.rb +26 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
- data/lib/rails_console_ai/channel/base.rb +23 -0
- data/lib/rails_console_ai/channel/console.rb +457 -0
- data/lib/rails_console_ai/channel/slack.rb +182 -0
- data/lib/rails_console_ai/configuration.rb +185 -0
- data/lib/rails_console_ai/console_methods.rb +277 -0
- data/lib/rails_console_ai/context_builder.rb +120 -0
- data/lib/rails_console_ai/conversation_engine.rb +1142 -0
- data/lib/rails_console_ai/engine.rb +5 -0
- data/lib/rails_console_ai/executor.rb +461 -0
- data/lib/rails_console_ai/providers/anthropic.rb +122 -0
- data/lib/rails_console_ai/providers/base.rb +118 -0
- data/lib/rails_console_ai/providers/bedrock.rb +171 -0
- data/lib/rails_console_ai/providers/local.rb +112 -0
- data/lib/rails_console_ai/providers/openai.rb +114 -0
- data/lib/rails_console_ai/railtie.rb +34 -0
- data/lib/rails_console_ai/repl.rb +65 -0
- data/lib/rails_console_ai/safety_guards.rb +207 -0
- data/lib/rails_console_ai/session_logger.rb +90 -0
- data/lib/rails_console_ai/slack_bot.rb +473 -0
- data/lib/rails_console_ai/storage/base.rb +27 -0
- data/lib/rails_console_ai/storage/file_storage.rb +63 -0
- data/lib/rails_console_ai/tools/code_tools.rb +126 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
- data/lib/rails_console_ai/tools/model_tools.rb +95 -0
- data/lib/rails_console_ai/tools/registry.rb +478 -0
- data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
- data/lib/rails_console_ai/version.rb +3 -0
- data/lib/rails_console_ai.rb +214 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- 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
|