rails_console_ai 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/app/controllers/rails_console_ai/application_controller.rb +28 -0
  6. data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
  7. data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
  8. data/app/models/rails_console_ai/session.rb +23 -0
  9. data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
  10. data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
  11. data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/generators/rails_console_ai/install_generator.rb +26 -0
  14. data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
  15. data/lib/rails_console_ai/channel/base.rb +23 -0
  16. data/lib/rails_console_ai/channel/console.rb +457 -0
  17. data/lib/rails_console_ai/channel/slack.rb +182 -0
  18. data/lib/rails_console_ai/configuration.rb +185 -0
  19. data/lib/rails_console_ai/console_methods.rb +277 -0
  20. data/lib/rails_console_ai/context_builder.rb +120 -0
  21. data/lib/rails_console_ai/conversation_engine.rb +1142 -0
  22. data/lib/rails_console_ai/engine.rb +5 -0
  23. data/lib/rails_console_ai/executor.rb +461 -0
  24. data/lib/rails_console_ai/providers/anthropic.rb +122 -0
  25. data/lib/rails_console_ai/providers/base.rb +118 -0
  26. data/lib/rails_console_ai/providers/bedrock.rb +171 -0
  27. data/lib/rails_console_ai/providers/local.rb +112 -0
  28. data/lib/rails_console_ai/providers/openai.rb +114 -0
  29. data/lib/rails_console_ai/railtie.rb +34 -0
  30. data/lib/rails_console_ai/repl.rb +65 -0
  31. data/lib/rails_console_ai/safety_guards.rb +207 -0
  32. data/lib/rails_console_ai/session_logger.rb +90 -0
  33. data/lib/rails_console_ai/slack_bot.rb +473 -0
  34. data/lib/rails_console_ai/storage/base.rb +27 -0
  35. data/lib/rails_console_ai/storage/file_storage.rb +63 -0
  36. data/lib/rails_console_ai/tools/code_tools.rb +126 -0
  37. data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
  38. data/lib/rails_console_ai/tools/model_tools.rb +95 -0
  39. data/lib/rails_console_ai/tools/registry.rb +478 -0
  40. data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
  41. data/lib/rails_console_ai/version.rb +3 -0
  42. data/lib/rails_console_ai.rb +214 -0
  43. data/lib/tasks/rails_console_ai.rake +7 -0
  44. metadata +152 -0
@@ -0,0 +1,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