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,457 @@
|
|
|
1
|
+
require 'readline'
|
|
2
|
+
require 'rails_console_ai/channel/base'
|
|
3
|
+
|
|
4
|
+
module RailsConsoleAI
|
|
5
|
+
module Channel
|
|
6
|
+
class Console < Base
|
|
7
|
+
attr_reader :real_stdout
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@real_stdout = $stdout
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def display(text)
|
|
14
|
+
$stdout.puts colorize(text, :cyan)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def display_dim(text)
|
|
18
|
+
$stdout.puts "\e[2m#{text}\e[0m"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def display_warning(text)
|
|
22
|
+
$stdout.puts colorize(text, :yellow)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def display_error(text)
|
|
26
|
+
$stderr.puts colorize(text, :red)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def display_code(code)
|
|
30
|
+
$stdout.puts
|
|
31
|
+
$stdout.puts colorize("# Generated code:", :yellow)
|
|
32
|
+
$stdout.puts highlight_code(code)
|
|
33
|
+
$stdout.puts
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def display_result(result)
|
|
37
|
+
full = "=> #{result.inspect}"
|
|
38
|
+
lines = full.lines
|
|
39
|
+
total_lines = lines.length
|
|
40
|
+
total_chars = full.length
|
|
41
|
+
|
|
42
|
+
if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
|
|
43
|
+
$stdout.puts colorize(full, :green)
|
|
44
|
+
else
|
|
45
|
+
truncated = lines.first(MAX_DISPLAY_LINES).join
|
|
46
|
+
truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
|
|
47
|
+
$stdout.puts colorize(truncated, :green)
|
|
48
|
+
|
|
49
|
+
omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
|
|
50
|
+
omitted_chars = [total_chars - truncated.length, 0].max
|
|
51
|
+
parts = []
|
|
52
|
+
parts << "#{omitted_lines} lines" if omitted_lines > 0
|
|
53
|
+
parts << "#{omitted_chars} chars" if omitted_chars > 0
|
|
54
|
+
|
|
55
|
+
@omitted_counter += 1
|
|
56
|
+
@omitted_outputs[@omitted_counter] = full
|
|
57
|
+
$stdout.puts colorize(" (omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prompt(text)
|
|
62
|
+
$stdout.print colorize(text, :cyan)
|
|
63
|
+
answer = $stdin.gets
|
|
64
|
+
return '(no answer provided)' if answer.nil?
|
|
65
|
+
answer.strip.empty? ? '(no answer provided)' : answer.strip
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def confirm(text)
|
|
69
|
+
$stdout.print colorize(text, :yellow)
|
|
70
|
+
$stdin.gets.to_s.strip.downcase
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def user_identity
|
|
74
|
+
RailsConsoleAI.current_user
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mode
|
|
78
|
+
'interactive'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def supports_editing?
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def edit_code(code)
|
|
86
|
+
open_in_editor(code)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def wrap_llm_call(&block)
|
|
90
|
+
with_escape_monitoring(&block)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Omitted output tracking (shared with Executor) ---
|
|
94
|
+
|
|
95
|
+
MAX_DISPLAY_LINES = 10
|
|
96
|
+
MAX_DISPLAY_CHARS = 2000
|
|
97
|
+
|
|
98
|
+
def init_omitted_tracking
|
|
99
|
+
@omitted_outputs = {}
|
|
100
|
+
@omitted_counter = 0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def expand_output(id)
|
|
104
|
+
@omitted_outputs[id]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Interactive loop ---
|
|
108
|
+
|
|
109
|
+
def interactive_loop(engine)
|
|
110
|
+
@engine = engine
|
|
111
|
+
engine.init_interactive
|
|
112
|
+
init_interactive_state
|
|
113
|
+
run_interactive_loop
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resume_interactive(engine, session)
|
|
117
|
+
@engine = engine
|
|
118
|
+
engine.init_interactive
|
|
119
|
+
init_interactive_state
|
|
120
|
+
|
|
121
|
+
# Restore state from the previous session
|
|
122
|
+
engine.restore_session(session)
|
|
123
|
+
|
|
124
|
+
# Seed the capture buffer with previous output so it's preserved on save
|
|
125
|
+
@interactive_console_capture.write(session.console_output.to_s)
|
|
126
|
+
|
|
127
|
+
# Replay to the user via the real stdout (bypass TeeIO to avoid double-capture)
|
|
128
|
+
if session.console_output && !session.console_output.strip.empty?
|
|
129
|
+
@real_stdout.puts "\e[2m--- Replaying previous session output ---\e[0m"
|
|
130
|
+
@real_stdout.puts session.console_output
|
|
131
|
+
@real_stdout.puts "\e[2m--- End of previous output ---\e[0m"
|
|
132
|
+
@real_stdout.puts
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
run_interactive_loop
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Provide access to the console capture for session logging
|
|
139
|
+
def console_capture_string
|
|
140
|
+
@interactive_console_capture&.string
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def write_to_capture(text)
|
|
144
|
+
@interactive_console_capture&.write(text)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def init_interactive_state
|
|
150
|
+
init_omitted_tracking
|
|
151
|
+
@interactive_console_capture = StringIO.new
|
|
152
|
+
@real_stdout = $stdout
|
|
153
|
+
$stdout = TeeIO.new(@real_stdout, @interactive_console_capture)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def run_interactive_loop
|
|
157
|
+
auto = RailsConsoleAI.configuration.auto_execute
|
|
158
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
159
|
+
name_display = @engine.session_name ? " (#{@engine.session_name})" : ""
|
|
160
|
+
@real_stdout.puts "\e[36mRailsConsoleAI interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
|
|
161
|
+
safe_info = guards.empty? ? '' : " | Safe mode: #{guards.enabled? ? 'ON' : 'OFF'} (/danger to toggle)"
|
|
162
|
+
@real_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle)#{safe_info} | > code | /usage | /cost | /compact | /think | /name <label>\e[0m"
|
|
163
|
+
|
|
164
|
+
if Readline.respond_to?(:parse_and_bind)
|
|
165
|
+
Readline.parse_and_bind('"\e[Z": "\C-a\C-k/auto\C-m"')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
loop do
|
|
169
|
+
input = Readline.readline("\001\e[33m\002ai> \001\e[0m\002", false)
|
|
170
|
+
break if input.nil?
|
|
171
|
+
|
|
172
|
+
input = input.strip
|
|
173
|
+
break if input.downcase == 'exit' || input.downcase == 'quit'
|
|
174
|
+
next if input.empty?
|
|
175
|
+
|
|
176
|
+
handled = handle_slash_command(input)
|
|
177
|
+
next if handled
|
|
178
|
+
|
|
179
|
+
# Direct code execution with ">" prefix
|
|
180
|
+
if input.start_with?('>') && !input.start_with?('>=')
|
|
181
|
+
handle_direct_execution(input)
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Add to Readline history
|
|
186
|
+
Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
|
|
187
|
+
|
|
188
|
+
# Auto-upgrade to thinking model on "think harder" phrases
|
|
189
|
+
@engine.upgrade_to_thinking_model if input =~ /think\s*harder/i
|
|
190
|
+
|
|
191
|
+
@engine.set_interactive_query(input)
|
|
192
|
+
@engine.add_user_message(input)
|
|
193
|
+
@interactive_console_capture.write("ai> #{input}\n")
|
|
194
|
+
@engine.log_interactive_turn
|
|
195
|
+
|
|
196
|
+
status = @engine.send_and_execute
|
|
197
|
+
if status == :interrupted
|
|
198
|
+
@engine.pop_last_message
|
|
199
|
+
@engine.log_interactive_turn
|
|
200
|
+
next
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if status == :error
|
|
204
|
+
$stdout.puts "\e[2m Attempting to fix...\e[0m"
|
|
205
|
+
@engine.log_interactive_turn
|
|
206
|
+
@engine.send_and_execute
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
@engine.log_interactive_turn
|
|
210
|
+
@engine.warn_if_history_large
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
$stdout = @real_stdout
|
|
214
|
+
@engine.finish_interactive_session
|
|
215
|
+
display_exit_info
|
|
216
|
+
rescue Interrupt
|
|
217
|
+
$stdout = @real_stdout if @real_stdout
|
|
218
|
+
$stdout.puts
|
|
219
|
+
@engine.finish_interactive_session
|
|
220
|
+
display_exit_info
|
|
221
|
+
rescue => e
|
|
222
|
+
$stdout = @real_stdout if @real_stdout
|
|
223
|
+
$stderr.puts "\e[31mRailsConsoleAI Error: #{e.class}: #{e.message}\e[0m"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def handle_slash_command(input)
|
|
227
|
+
case input
|
|
228
|
+
when '?', '/'
|
|
229
|
+
display_help
|
|
230
|
+
when '/auto'
|
|
231
|
+
RailsConsoleAI.configuration.auto_execute = !RailsConsoleAI.configuration.auto_execute
|
|
232
|
+
mode = RailsConsoleAI.configuration.auto_execute ? 'ON' : 'OFF'
|
|
233
|
+
@real_stdout.puts "\e[36m Auto-execute: #{mode}\e[0m"
|
|
234
|
+
when '/danger'
|
|
235
|
+
toggle_danger
|
|
236
|
+
when '/safe'
|
|
237
|
+
display_safe_status
|
|
238
|
+
when '/usage'
|
|
239
|
+
@engine.display_session_summary
|
|
240
|
+
when '/debug'
|
|
241
|
+
RailsConsoleAI.configuration.debug = !RailsConsoleAI.configuration.debug
|
|
242
|
+
mode = RailsConsoleAI.configuration.debug ? 'ON' : 'OFF'
|
|
243
|
+
@real_stdout.puts "\e[36m Debug: #{mode}\e[0m"
|
|
244
|
+
when '/compact'
|
|
245
|
+
@engine.compact_history
|
|
246
|
+
when '/system'
|
|
247
|
+
@real_stdout.puts "\e[2m#{@engine.context}\e[0m"
|
|
248
|
+
when '/context'
|
|
249
|
+
@engine.display_conversation
|
|
250
|
+
when '/cost'
|
|
251
|
+
@engine.display_cost_summary
|
|
252
|
+
when '/think'
|
|
253
|
+
@engine.upgrade_to_thinking_model
|
|
254
|
+
when /\A\/expand/
|
|
255
|
+
expand_id = input.sub('/expand', '').strip.to_i
|
|
256
|
+
full_output = expand_output(expand_id)
|
|
257
|
+
if full_output
|
|
258
|
+
@real_stdout.puts full_output
|
|
259
|
+
else
|
|
260
|
+
@real_stdout.puts "\e[33mNo omitted output with id #{expand_id}\e[0m"
|
|
261
|
+
end
|
|
262
|
+
when /\A\/name/
|
|
263
|
+
handle_name_command(input)
|
|
264
|
+
else
|
|
265
|
+
return false
|
|
266
|
+
end
|
|
267
|
+
true
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def handle_direct_execution(input)
|
|
271
|
+
raw_code = input.sub(/\A>\s?/, '')
|
|
272
|
+
Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
|
|
273
|
+
@interactive_console_capture.write("ai> #{input}\n")
|
|
274
|
+
@engine.execute_direct(raw_code)
|
|
275
|
+
@engine.log_interactive_turn
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def toggle_danger
|
|
279
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
280
|
+
if guards.empty?
|
|
281
|
+
@real_stdout.puts "\e[33m No safety guards configured.\e[0m"
|
|
282
|
+
elsif guards.enabled?
|
|
283
|
+
guards.disable!
|
|
284
|
+
@real_stdout.puts "\e[31m Safe mode: OFF (writes and side effects allowed!)\e[0m"
|
|
285
|
+
else
|
|
286
|
+
guards.enable!
|
|
287
|
+
@real_stdout.puts "\e[32m Safe mode: ON (#{guards.names.join(', ')} guarded)\e[0m"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def display_safe_status
|
|
292
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
293
|
+
if guards.empty?
|
|
294
|
+
@real_stdout.puts "\e[33m No safety guards configured.\e[0m"
|
|
295
|
+
else
|
|
296
|
+
status = guards.enabled? ? "\e[32mON\e[0m" : "\e[31mOFF\e[0m"
|
|
297
|
+
@real_stdout.puts "\e[36m Safe mode: #{status}\e[0m"
|
|
298
|
+
@real_stdout.puts "\e[2m Guards: #{guards.names.join(', ')}\e[0m"
|
|
299
|
+
unless guards.allowlist.empty?
|
|
300
|
+
@real_stdout.puts "\e[2m Allowlist:\e[0m"
|
|
301
|
+
guards.allowlist.each do |guard_name, keys|
|
|
302
|
+
keys.each do |key|
|
|
303
|
+
label = key.is_a?(Regexp) ? key.inspect : key.to_s
|
|
304
|
+
@real_stdout.puts "\e[2m :#{guard_name} → #{label}\e[0m"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def handle_name_command(input)
|
|
312
|
+
name = input.sub('/name', '').strip.gsub(/\A(['"])(.*)\1\z/, '\2')
|
|
313
|
+
if name.empty?
|
|
314
|
+
if @engine.session_name
|
|
315
|
+
@real_stdout.puts "\e[36m Session name: #{@engine.session_name}\e[0m"
|
|
316
|
+
else
|
|
317
|
+
@real_stdout.puts "\e[33m Usage: /name <label> (e.g. /name salesforce_user_123)\e[0m"
|
|
318
|
+
end
|
|
319
|
+
else
|
|
320
|
+
@engine.set_session_name(name)
|
|
321
|
+
@real_stdout.puts "\e[36m Session named: #{name}\e[0m"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def display_help
|
|
326
|
+
auto = RailsConsoleAI.configuration.auto_execute ? 'ON' : 'OFF'
|
|
327
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
328
|
+
@real_stdout.puts "\e[36m Commands:\e[0m"
|
|
329
|
+
@real_stdout.puts "\e[2m /auto Toggle auto-execute (currently #{auto}) (Shift-Tab)\e[0m"
|
|
330
|
+
unless guards.empty?
|
|
331
|
+
safe_status = guards.enabled? ? 'ON' : 'OFF'
|
|
332
|
+
@real_stdout.puts "\e[2m /danger Toggle safe mode (currently #{safe_status})\e[0m"
|
|
333
|
+
@real_stdout.puts "\e[2m /safe Show safety guard status\e[0m"
|
|
334
|
+
end
|
|
335
|
+
@real_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
|
|
336
|
+
@real_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
|
|
337
|
+
@real_stdout.puts "\e[2m /usage Show session token totals\e[0m"
|
|
338
|
+
@real_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
|
|
339
|
+
@real_stdout.puts "\e[2m /name <lbl> Name this session for easy resume\e[0m"
|
|
340
|
+
@real_stdout.puts "\e[2m /context Show conversation history sent to the LLM\e[0m"
|
|
341
|
+
@real_stdout.puts "\e[2m /system Show the system prompt\e[0m"
|
|
342
|
+
@real_stdout.puts "\e[2m /expand <id> Show full omitted output\e[0m"
|
|
343
|
+
@real_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
|
|
344
|
+
@real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
|
|
345
|
+
@real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def display_exit_info
|
|
349
|
+
@engine.display_session_summary
|
|
350
|
+
session_id = @engine.interactive_session_id
|
|
351
|
+
if session_id
|
|
352
|
+
$stdout.puts "\e[36mSession ##{session_id} saved.\e[0m"
|
|
353
|
+
if @engine.session_name
|
|
354
|
+
$stdout.puts "\e[2m Resume with: ai_resume \"#{@engine.session_name}\"\e[0m"
|
|
355
|
+
else
|
|
356
|
+
$stdout.puts "\e[2m Name it: ai_name #{session_id}, \"descriptive_name\"\e[0m"
|
|
357
|
+
$stdout.puts "\e[2m Resume it: ai_resume #{session_id}\e[0m"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
$stdout.puts "\e[36mLeft RailsConsoleAI interactive mode.\e[0m"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# --- Terminal helpers ---
|
|
364
|
+
|
|
365
|
+
def with_escape_monitoring
|
|
366
|
+
require 'io/console'
|
|
367
|
+
return yield unless $stdin.respond_to?(:raw)
|
|
368
|
+
|
|
369
|
+
monitor = Thread.new do
|
|
370
|
+
Thread.current.report_on_exception = false
|
|
371
|
+
$stdin.raw do |io|
|
|
372
|
+
loop do
|
|
373
|
+
break if Thread.current[:stop]
|
|
374
|
+
ready = IO.select([io], nil, nil, 0.2)
|
|
375
|
+
next unless ready
|
|
376
|
+
|
|
377
|
+
char = io.read_nonblock(1) rescue nil
|
|
378
|
+
next unless char
|
|
379
|
+
|
|
380
|
+
if char == "\x03"
|
|
381
|
+
Thread.main.raise(Interrupt)
|
|
382
|
+
break
|
|
383
|
+
elsif char == "\e"
|
|
384
|
+
seq = IO.select([io], nil, nil, 0.05)
|
|
385
|
+
if seq
|
|
386
|
+
io.read_nonblock(10) rescue nil
|
|
387
|
+
else
|
|
388
|
+
Thread.main.raise(Interrupt)
|
|
389
|
+
break
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
|
|
395
|
+
# stdin is not a TTY — silently skip
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
begin
|
|
399
|
+
yield
|
|
400
|
+
ensure
|
|
401
|
+
monitor[:stop] = true
|
|
402
|
+
monitor.join(1) rescue nil
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def open_in_editor(code)
|
|
407
|
+
require 'tempfile'
|
|
408
|
+
editor = ENV['EDITOR'] || 'vi'
|
|
409
|
+
tmpfile = Tempfile.new(['rails_console_ai', '.rb'])
|
|
410
|
+
tmpfile.write(code)
|
|
411
|
+
tmpfile.flush
|
|
412
|
+
system("#{editor} #{tmpfile.path}")
|
|
413
|
+
File.read(tmpfile.path)
|
|
414
|
+
rescue => e
|
|
415
|
+
$stderr.puts colorize("Editor error: #{e.message}", :red)
|
|
416
|
+
code
|
|
417
|
+
ensure
|
|
418
|
+
tmpfile.close! if tmpfile
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def highlight_code(code)
|
|
422
|
+
if coderay_available?
|
|
423
|
+
CodeRay.scan(code, :ruby).terminal
|
|
424
|
+
else
|
|
425
|
+
colorize(code, :white)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def coderay_available?
|
|
430
|
+
return @coderay_available unless @coderay_available.nil?
|
|
431
|
+
@coderay_available = begin
|
|
432
|
+
require 'coderay'
|
|
433
|
+
true
|
|
434
|
+
rescue LoadError
|
|
435
|
+
false
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
COLORS = {
|
|
440
|
+
red: "\e[31m",
|
|
441
|
+
green: "\e[32m",
|
|
442
|
+
yellow: "\e[33m",
|
|
443
|
+
cyan: "\e[36m",
|
|
444
|
+
white: "\e[37m",
|
|
445
|
+
reset: "\e[0m"
|
|
446
|
+
}.freeze
|
|
447
|
+
|
|
448
|
+
def colorize(text, color)
|
|
449
|
+
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
450
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
451
|
+
else
|
|
452
|
+
text
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
require 'rails_console_ai/channel/base'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAI
|
|
4
|
+
module Channel
|
|
5
|
+
class Slack < Base
|
|
6
|
+
ANSI_REGEX = /\e\[[0-9;]*m/
|
|
7
|
+
|
|
8
|
+
THINKING_MESSAGES = [
|
|
9
|
+
"Thinking...",
|
|
10
|
+
"Reticulating splines...",
|
|
11
|
+
"Scrubbing encryption bits...",
|
|
12
|
+
"Consulting the oracle...",
|
|
13
|
+
"Rummaging through the database...",
|
|
14
|
+
"Warming up the hamster wheel...",
|
|
15
|
+
"Polishing the pixels...",
|
|
16
|
+
"Untangling the spaghetti code...",
|
|
17
|
+
"Asking the magic 8-ball...",
|
|
18
|
+
"Counting all the things...",
|
|
19
|
+
"Herding the electrons...",
|
|
20
|
+
"Dusting off the old records...",
|
|
21
|
+
"Feeding the algorithms...",
|
|
22
|
+
"Shaking the data tree...",
|
|
23
|
+
"Bribing the servers...",
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(slack_bot:, channel_id:, thread_ts:, user_name: nil)
|
|
27
|
+
@slack_bot = slack_bot
|
|
28
|
+
@channel_id = channel_id
|
|
29
|
+
@thread_ts = thread_ts
|
|
30
|
+
@user_name = user_name
|
|
31
|
+
@reply_queue = Queue.new
|
|
32
|
+
@cancelled = false
|
|
33
|
+
@log_prefix = "[#{@channel_id}/#{@thread_ts}] @#{@user_name}"
|
|
34
|
+
@output_log = StringIO.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cancel!
|
|
38
|
+
@cancelled = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cancelled?
|
|
42
|
+
@cancelled
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display(text)
|
|
46
|
+
post(strip_ansi(text))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def display_dim(text)
|
|
50
|
+
stripped = strip_ansi(text).strip
|
|
51
|
+
if stripped =~ /\AThinking\.\.\.|\ACalling LLM/
|
|
52
|
+
post(random_thinking_message)
|
|
53
|
+
elsif stripped =~ /\AAttempting to fix|\ACancelled|\A_session:/
|
|
54
|
+
post(stripped)
|
|
55
|
+
else
|
|
56
|
+
# Log for engineers but don't post to Slack
|
|
57
|
+
@output_log.write("#{stripped}\n")
|
|
58
|
+
$stdout.puts "#{@log_prefix} (dim) #{stripped}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def display_warning(text)
|
|
63
|
+
post(":warning: #{strip_ansi(text)}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def display_error(text)
|
|
67
|
+
post(":x: #{strip_ansi(text)}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def display_code(_code)
|
|
71
|
+
# Don't post raw code/plan steps to Slack — non-technical users don't need to see Ruby
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def display_result_output(output)
|
|
76
|
+
text = strip_ansi(output).strip
|
|
77
|
+
return if text.empty?
|
|
78
|
+
text = text[0, 3000] + "\n... (truncated)" if text.length > 3000
|
|
79
|
+
post("```#{text}```")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def display_result(_result)
|
|
83
|
+
# Don't post raw return values to Slack — the LLM formats output via puts
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prompt(text)
|
|
88
|
+
post(strip_ansi(text))
|
|
89
|
+
@reply_queue.pop
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def confirm(_text)
|
|
93
|
+
'y'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def user_identity
|
|
97
|
+
@user_name
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def mode
|
|
101
|
+
'slack'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def supports_danger?
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def supports_editing?
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def wrap_llm_call(&block)
|
|
113
|
+
yield
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def system_instructions
|
|
117
|
+
<<~INSTRUCTIONS.strip
|
|
118
|
+
## Response Formatting (Slack Channel)
|
|
119
|
+
|
|
120
|
+
You are responding to non-technical users in Slack. Follow these rules:
|
|
121
|
+
|
|
122
|
+
- Slack does NOT support markdown tables. For tabular data, use `puts` to print
|
|
123
|
+
a plain-text table inside a code block. Use fixed-width columns with padding so
|
|
124
|
+
columns align. Example format:
|
|
125
|
+
```
|
|
126
|
+
ID Name Email
|
|
127
|
+
123 John Smith john@example.com
|
|
128
|
+
456 Jane Doe jane@example.com
|
|
129
|
+
```
|
|
130
|
+
- Use `puts` with formatted output instead of returning arrays or hashes
|
|
131
|
+
- Summarize findings in plain, simple language
|
|
132
|
+
- Do NOT show technical details like SQL queries, token counts, or class names
|
|
133
|
+
- Keep explanations simple and jargon-free
|
|
134
|
+
- Never return raw Ruby objects — always present data in a human-readable way
|
|
135
|
+
- The output of `puts` in your code is automatically shown to the user. Do NOT
|
|
136
|
+
repeat or re-display data that your code already printed via `puts`.
|
|
137
|
+
Just add a brief summary after (e.g. "10 events found" or "Let me know if you need more detail").
|
|
138
|
+
- This is a live production database — other processes, users, and background jobs are
|
|
139
|
+
constantly changing data. Never assume results will be the same as a previous query.
|
|
140
|
+
Always re-run queries when asked, even if you just ran the same one.
|
|
141
|
+
INSTRUCTIONS
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def log_input(text)
|
|
145
|
+
@output_log.write("@#{@user_name}: #{text}\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Called by SlackBot when a thread reply arrives
|
|
149
|
+
def receive_reply(text)
|
|
150
|
+
@output_log.write("@#{@user_name}: #{text}\n")
|
|
151
|
+
@reply_queue.push(text)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def console_capture_string
|
|
155
|
+
@output_log.string
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def post(text)
|
|
161
|
+
return if text.nil? || text.strip.empty?
|
|
162
|
+
@output_log.write("#{text}\n")
|
|
163
|
+
$stdout.puts "#{@log_prefix} >> #{text}"
|
|
164
|
+
@slack_bot.send(:post_message,
|
|
165
|
+
channel: @channel_id,
|
|
166
|
+
thread_ts: @thread_ts,
|
|
167
|
+
text: text
|
|
168
|
+
)
|
|
169
|
+
rescue => e
|
|
170
|
+
RailsConsoleAI.logger.error("Slack post failed: #{e.message}")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def random_thinking_message
|
|
174
|
+
THINKING_MESSAGES.sample
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def strip_ansi(text)
|
|
178
|
+
text.to_s.gsub(ANSI_REGEX, '')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|