rails_console_ai 0.16.0 → 0.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e038cc77de185b7afbbc1d859400948335adfe8d69e714ef35d57a8c49fb8b34
4
- data.tar.gz: e614a1ccaa212bfaa93dfb1770f3d8c5b39ee2c1e678ba3a4cab3786f7e0c86f
3
+ metadata.gz: f6fc753a055c35cdf2aec7ffa98dff0cf5b0dd649e9fd44048d5f43577a69a9a
4
+ data.tar.gz: c9ce948130dae8a58ee8eaaa6d348f00369a9c2f63cb253a9728323493df04e6
5
5
  SHA512:
6
- metadata.gz: ba68a62e707d3b35f2fc0f7f24d9d73d930c951936f37f8ec60c34dce7975bf67e184bf05fd418db1773c9ec7d58455cf25c122477ec67bde8fcee47247fcb25
7
- data.tar.gz: 3fa2a64467e090b52ae68b21f93e9a1cc15f03b445f8f9a02ec0ca51d74514211f6a4eeff551ec91858adccb7c59e1a46218191480c7e749c28538c665897287
6
+ metadata.gz: c20f24123aafbef6c1e2dd218ba47f63c3773e0bae332081422aba1f52a821bf5e94baf09a5db13919e61a12cdb40124de139739a352fb9b8486f3cf3de9be77
7
+ data.tar.gz: 62808844bd51a0a5f3aadae221d2a9641b47dc497a548c0edbc1ccda247eaf0e24785920621850b387165d20698f9ccf77aa1f14b59f9432f0c9dcc17c034aac
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.18.0]
6
+
7
+ - Handle "smart" quotes coming from Slack
8
+ - Eager load when Slack bot starts up
9
+ - Handle `>` in Slack for direct code execution
10
+ - Handle stopping of sessions in Slack bot by recording the stop
11
+
12
+ ## [0.17.0]
13
+
14
+ - Add `/retry` command
15
+ - Print provider information when `ai!` starts
16
+ - Keep Slack bot alive during long-running sessions
17
+ - Improve Slack bot log prefixes for production log search
18
+ - Catch safety errors even when swallowed by executed code
19
+ - Fix Bedrock handling of multiple tool results
20
+
5
21
  ## [0.16.0]
6
22
 
7
23
  - Run migrations during setup
@@ -158,6 +158,8 @@ module RailsConsoleAi
158
158
  guards = RailsConsoleAi.configuration.safety_guards
159
159
  name_display = @engine.session_name ? " (#{@engine.session_name})" : ""
160
160
  @real_stdout.puts "\e[36mRailsConsoleAi interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
161
+ config = RailsConsoleAi.configuration
162
+ @real_stdout.puts "\e[2m Provider: #{config.provider} | Model: #{config.resolved_model}\e[0m"
161
163
  safe_info = guards.empty? ? '' : " | Safe mode: #{guards.enabled? ? 'ON' : 'OFF'} (/danger to toggle)"
162
164
  @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
165
 
@@ -259,6 +261,8 @@ module RailsConsoleAi
259
261
  else
260
262
  @real_stdout.puts "\e[33mNo omitted output with id #{expand_id}\e[0m"
261
263
  end
264
+ when '/retry'
265
+ retry_last_code
262
266
  when /\A\/name/
263
267
  handle_name_command(input)
264
268
  else
@@ -267,6 +271,10 @@ module RailsConsoleAi
267
271
  true
268
272
  end
269
273
 
274
+ def retry_last_code
275
+ @engine.retry_last_code
276
+ end
277
+
270
278
  def handle_direct_execution(input)
271
279
  raw_code = input.sub(/\A>\s?/, '')
272
280
  Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
@@ -341,6 +349,7 @@ module RailsConsoleAi
341
349
  @real_stdout.puts "\e[2m /system Show the system prompt\e[0m"
342
350
  @real_stdout.puts "\e[2m /expand <id> Show full omitted output\e[0m"
343
351
  @real_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
352
+ @real_stdout.puts "\e[2m /retry Re-execute the last code block\e[0m"
344
353
  @real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
345
354
  @real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
346
355
  end
@@ -37,14 +37,14 @@ module RailsConsoleAi
37
37
  elsif stripped =~ /\ACalling LLM/
38
38
  # Technical LLM round status — suppress in Slack
39
39
  @output_log.write("#{stripped}\n")
40
- $stdout.puts "#{@log_prefix} (dim) #{stripped}"
40
+ STDOUT.puts "#{@log_prefix} (dim) #{stripped}"
41
41
  elsif raw =~ /\A {2,4}\S/ && stripped.length > 10
42
42
  # LLM thinking text (2-space indent from conversation engine) — show as status
43
43
  post(stripped)
44
44
  else
45
45
  # Tool result previews (5+ space indent) and other technical noise — log only
46
46
  @output_log.write("#{stripped}\n")
47
- $stdout.puts "#{@log_prefix} (dim) #{stripped}"
47
+ STDOUT.puts "#{@log_prefix} (dim) #{stripped}"
48
48
  end
49
49
  end
50
50
 
@@ -150,7 +150,7 @@ module RailsConsoleAi
150
150
  def post(text)
151
151
  return if text.nil? || text.strip.empty?
152
152
  @output_log.write("#{text}\n")
153
- $stdout.puts "#{@log_prefix} >> #{text}"
153
+ STDOUT.puts "#{@log_prefix} >> #{text}"
154
154
  @slack_bot.send(:post_message,
155
155
  channel: @channel_id,
156
156
  thread_ts: @thread_ts,
@@ -221,6 +221,16 @@ module RailsConsoleAi
221
221
  end
222
222
  end
223
223
 
224
+ def retry_last_code
225
+ code = @last_interactive_code
226
+ unless code
227
+ @channel.display_warning("No code to retry.")
228
+ return
229
+ end
230
+ @channel.display_dim(" Retrying last code...")
231
+ execute_direct(code)
232
+ end
233
+
224
234
  def execute_direct(raw_code)
225
235
  exec_result = @executor.execute(raw_code)
226
236
 
@@ -1,4 +1,5 @@
1
1
  require 'stringio'
2
+ require_relative 'safety_guards'
2
3
 
3
4
  module RailsConsoleAi
4
5
  # Writes to two IO streams simultaneously
@@ -97,12 +98,25 @@ module RailsConsoleAi
97
98
  # Tee output: capture it and also print to the real stdout
98
99
  $stdout = TeeIO.new(old_stdout, captured_output)
99
100
 
101
+ RailsConsoleAi::SafetyError.clear!
102
+
100
103
  result = with_safety_guards do
101
104
  binding_context.eval(code, "(rails_console_ai)", 1)
102
105
  end
103
106
 
104
107
  $stdout = old_stdout
105
108
 
109
+ # Check if a SafetyError was raised but swallowed by a rescue inside the eval'd code
110
+ if (swallowed = RailsConsoleAi::SafetyError.last_raised)
111
+ RailsConsoleAi::SafetyError.clear!
112
+ @last_error = "SafetyError: #{swallowed.message}"
113
+ @last_safety_error = true
114
+ @last_safety_exception = swallowed
115
+ display_error("Blocked: #{swallowed.message}")
116
+ @last_output = captured_output&.string
117
+ return nil
118
+ end
119
+
106
120
  # Send captured puts output through channel before the return value
107
121
  if @channel && !captured_output.string.empty?
108
122
  @channel.display_result_output(captured_output.string)
@@ -114,6 +128,7 @@ module RailsConsoleAi
114
128
  result
115
129
  rescue RailsConsoleAi::SafetyError => e
116
130
  $stdout = old_stdout if old_stdout
131
+ RailsConsoleAi::SafetyError.clear!
117
132
  @last_error = "SafetyError: #{e.message}"
118
133
  @last_safety_error = true
119
134
  @last_safety_exception = e
@@ -0,0 +1,50 @@
1
+ module RailsConsoleAi
2
+ class PrefixedIO
3
+ def initialize(io)
4
+ @io = io
5
+ end
6
+
7
+ def write(str)
8
+ prefix = Thread.current[:log_prefix]
9
+ if prefix && str.is_a?(String) && !str.strip.empty?
10
+ prefixed = str.gsub(/^(?=.)/, "#{prefix} ")
11
+ @io.write(prefixed)
12
+ else
13
+ @io.write(str)
14
+ end
15
+ end
16
+
17
+ def puts(*args)
18
+ prefix = Thread.current[:log_prefix]
19
+ if prefix
20
+ args = [""] if args.empty?
21
+ args.each do |a|
22
+ line = a.to_s
23
+ if line.strip.empty?
24
+ @io.write("\n")
25
+ else
26
+ @io.write("#{prefix} #{line}\n")
27
+ end
28
+ end
29
+ else
30
+ @io.puts(*args)
31
+ end
32
+ end
33
+
34
+ def print(*args)
35
+ @io.print(*args)
36
+ end
37
+
38
+ def flush
39
+ @io.flush
40
+ end
41
+
42
+ def respond_to_missing?(method, include_private = false)
43
+ @io.respond_to?(method, include_private) || super
44
+ end
45
+
46
+ def method_missing(method, *args, &block)
47
+ @io.send(method, *args, &block)
48
+ end
49
+ end
50
+ end
@@ -115,7 +115,7 @@ module RailsConsoleAi
115
115
  end
116
116
 
117
117
  def format_messages(messages)
118
- messages.map do |msg|
118
+ formatted = messages.map do |msg|
119
119
  content = if msg[:content].is_a?(Array)
120
120
  msg[:content]
121
121
  else
@@ -123,6 +123,18 @@ module RailsConsoleAi
123
123
  end
124
124
  { role: msg[:role].to_s, content: content }
125
125
  end
126
+
127
+ # Bedrock requires all tool_result blocks for a single assistant turn
128
+ # to be in one user message. Merge consecutive same-role messages.
129
+ merged = []
130
+ formatted.each do |msg|
131
+ if merged.last && merged.last[:role] == msg[:role]
132
+ merged.last[:content].concat(msg[:content])
133
+ else
134
+ merged << msg
135
+ end
136
+ end
137
+ merged
126
138
  end
127
139
 
128
140
  def extract_text(response)
@@ -5,10 +5,21 @@ module RailsConsoleAi
5
5
  class SafetyError < StandardError
6
6
  attr_reader :guard, :blocked_key
7
7
 
8
+ # Thread-local tracking so the executor can detect safety errors
9
+ # even when swallowed by a rescue inside eval'd code.
10
+ def self.last_raised
11
+ Thread.current[:rails_console_ai_last_safety_error]
12
+ end
13
+
14
+ def self.clear!
15
+ Thread.current[:rails_console_ai_last_safety_error] = nil
16
+ end
17
+
8
18
  def initialize(message, guard: nil, blocked_key: nil)
9
19
  super(message)
10
20
  @guard = guard
11
21
  @blocked_key = blocked_key
22
+ Thread.current[:rails_console_ai_last_safety_error] = self
12
23
  end
13
24
  end
14
25
 
@@ -2,6 +2,7 @@ require 'json'
2
2
  require 'uri'
3
3
  require 'net/http'
4
4
  require 'openssl'
5
+ require 'rails_console_ai/prefixed_io'
5
6
  require 'rails_console_ai/channel/slack'
6
7
  require 'rails_console_ai/conversation_engine'
7
8
  require 'rails_console_ai/context_builder'
@@ -10,6 +11,9 @@ require 'rails_console_ai/executor'
10
11
 
11
12
  module RailsConsoleAi
12
13
  class SlackBot
14
+ PING_INTERVAL = 30 # seconds — send ping if no data received
15
+ PONG_TIMEOUT = 60 # seconds — reconnect if no pong after ping
16
+
13
17
  def initialize
14
18
  @bot_token = RailsConsoleAi.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
15
19
  @app_token = RailsConsoleAi.configuration.slack_app_token || ENV['SLACK_APP_TOKEN']
@@ -26,6 +30,18 @@ module RailsConsoleAi
26
30
  end
27
31
 
28
32
  def start
33
+ $stdout.sync = true
34
+ $stderr.sync = true
35
+ $stdout = RailsConsoleAi::PrefixedIO.new($stdout) unless $stdout.is_a?(RailsConsoleAi::PrefixedIO)
36
+ $stderr = RailsConsoleAi::PrefixedIO.new($stderr) unless $stderr.is_a?(RailsConsoleAi::PrefixedIO)
37
+
38
+ # Eager load the Rails app so class-level initializers (e.g. Secret.get)
39
+ # run before safety guards are active during user code execution.
40
+ if defined?(Rails) && Rails.application.respond_to?(:eager_load!)
41
+ puts "Eager loading application..."
42
+ Rails.application.eager_load!
43
+ end
44
+
29
45
  @bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
30
46
  log_startup
31
47
 
@@ -78,9 +94,27 @@ module RailsConsoleAi
78
94
 
79
95
  puts "Connected to Slack Socket Mode."
80
96
 
81
- # Main read loop
97
+ # Main read loop with keepalive
98
+ last_activity = Time.now
99
+ ping_sent = false
100
+
82
101
  loop do
102
+ ready = IO.select([ssl.to_io], nil, nil, PING_INTERVAL)
103
+
104
+ if ready.nil?
105
+ # Timeout — no data received
106
+ if ping_sent && (Time.now - last_activity) > PONG_TIMEOUT
107
+ puts "Slack connection timed out (no pong received). Reconnecting..."
108
+ break
109
+ end
110
+ send_ws_ping(ssl)
111
+ ping_sent = true
112
+ next
113
+ end
114
+
83
115
  data = read_ws_frame(ssl)
116
+ last_activity = Time.now
117
+ ping_sent = false
84
118
  next unless data
85
119
 
86
120
  begin
@@ -130,6 +164,11 @@ module RailsConsoleAi
130
164
  send_ws_pong(ssl, payload)
131
165
  return nil
132
166
  end
167
+ # Handle pong (opcode 10) — response to our keepalive ping
168
+ if opcode == 0xA
169
+ read_ws_payload(ssl) # consume payload
170
+ return nil
171
+ end
133
172
  # Close frame (opcode 8)
134
173
  return nil if opcode == 8
135
174
  # Only process text frames (opcode 1)
@@ -194,6 +233,14 @@ module RailsConsoleAi
194
233
  ssl.write(frame)
195
234
  end
196
235
 
236
+ def send_ws_ping(ssl)
237
+ mask_key = 4.times.map { rand(256) }
238
+ frame = [0x89].pack("C") # FIN + ping opcode
239
+ frame << [0x80].pack("C") # masked, zero-length payload
240
+ frame << mask_key.pack("C*")
241
+ ssl.write(frame)
242
+ end
243
+
197
244
  # --- Slack Web API (minimal, uses Net::HTTP) ---
198
245
 
199
246
  def slack_api(method, token: @bot_token, **params)
@@ -232,7 +279,7 @@ module RailsConsoleAi
232
279
  return if event[:user] == @bot_user_id
233
280
  return if event[:subtype]
234
281
 
235
- text = event[:text]
282
+ text = unescape_slack(event[:text])
236
283
  return unless text && !text.strip.empty?
237
284
 
238
285
  channel_id = event[:channel]
@@ -276,6 +323,15 @@ module RailsConsoleAi
276
323
  return
277
324
  end
278
325
 
326
+ # Direct code execution: "> User.count" runs code without LLM
327
+ if text.strip.start_with?('>')
328
+ raw_code = text.strip.sub(/\A>\s*/, '')
329
+ unless raw_code.empty?
330
+ handle_direct_code(session, channel_id, thread_ts, raw_code, user_name)
331
+ return
332
+ end
333
+ end
334
+
279
335
  if session
280
336
  handle_thread_reply(session, text.strip)
281
337
  else
@@ -309,6 +365,7 @@ module RailsConsoleAi
309
365
 
310
366
  session[:thread] = Thread.new do
311
367
  Thread.current.report_on_exception = false
368
+ Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}] @#{user_name}"
312
369
  begin
313
370
  channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
314
371
  if restored
@@ -351,6 +408,7 @@ module RailsConsoleAi
351
408
  # Otherwise treat as a new message in the conversation
352
409
  session[:thread] = Thread.new do
353
410
  Thread.current.report_on_exception = false
411
+ Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
354
412
  begin
355
413
  engine.process_message(text)
356
414
  rescue => e
@@ -362,11 +420,76 @@ module RailsConsoleAi
362
420
  end
363
421
  end
364
422
 
423
+ def handle_direct_code(session, channel_id, thread_ts, raw_code, user_name)
424
+ # Ensure a session exists for this thread
425
+ unless session
426
+ start_direct_session(channel_id, thread_ts, user_name)
427
+ session = @mutex.synchronize { @sessions[thread_ts] }
428
+ end
429
+
430
+ channel = session[:channel]
431
+ engine = session[:engine]
432
+
433
+ session[:thread] = Thread.new do
434
+ Thread.current.report_on_exception = false
435
+ Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
436
+ begin
437
+ engine.execute_direct(raw_code)
438
+ # Post return value to Slack (execute_direct suppresses it via display_result no-op)
439
+ result_value = engine.instance_variable_get(:@last_interactive_result)
440
+ unless result_value.nil?
441
+ display_text = "=> #{result_value}"
442
+ display_text = display_text[0, 3000] + "\n... (truncated)" if display_text.length > 3000
443
+ post_message(channel: channel_id, thread_ts: thread_ts, text: "```#{display_text}```")
444
+ end
445
+ engine.send(:log_interactive_turn)
446
+ rescue => e
447
+ channel.display_error("Error: #{e.class}: #{e.message}")
448
+ RailsConsoleAi.logger.error("SlackBot direct code error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
449
+ ensure
450
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
451
+ end
452
+ end
453
+ end
454
+
455
+ def start_direct_session(channel_id, thread_ts, user_name)
456
+ channel = Channel::Slack.new(
457
+ slack_bot: self,
458
+ channel_id: channel_id,
459
+ thread_ts: thread_ts,
460
+ user_name: user_name
461
+ )
462
+
463
+ sandbox_binding = Object.new.instance_eval { binding }
464
+ engine = ConversationEngine.new(
465
+ binding_context: sandbox_binding,
466
+ channel: channel,
467
+ slack_thread_ts: thread_ts
468
+ )
469
+
470
+ restore_from_db(engine, thread_ts)
471
+ engine.init_interactive unless engine.instance_variable_get(:@interactive_start)
472
+
473
+ session = { channel: channel, engine: engine, thread: nil }
474
+ @mutex.synchronize { @sessions[thread_ts] = session }
475
+ end
476
+
365
477
  def cancel_session(session, channel_id, thread_ts)
366
478
  if session
367
479
  session[:channel].cancel!
368
480
  session[:channel].display("Stopped.")
369
481
  puts "[#{channel_id}/#{thread_ts}] cancel requested"
482
+
483
+ # Record stop in conversation history so restored sessions know
484
+ # the previous topic was abandoned by the user
485
+ engine = session[:engine]
486
+ engine.history << { role: :user, content: "stop" }
487
+ engine.history << { role: :assistant, content: "Stopped. Awaiting new instructions." }
488
+ begin
489
+ engine.send(:log_interactive_turn)
490
+ rescue => e
491
+ RailsConsoleAi.logger.warn("SlackBot: failed to save cancel state: #{e.message}")
492
+ end
370
493
  else
371
494
  post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
372
495
  puts "[#{channel_id}/#{thread_ts}] cancel: no session"
@@ -412,6 +535,13 @@ module RailsConsoleAi
412
535
  { "ok" => false, "error" => e.message }
413
536
  end
414
537
 
538
+ def unescape_slack(text)
539
+ return text unless text
540
+ text.gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">")
541
+ .gsub("\u2018", "'").gsub("\u2019", "'") # smart single quotes → straight
542
+ .gsub("\u201C", '"').gsub("\u201D", '"') # smart double quotes → straight
543
+ end
544
+
415
545
  def waiting_for_reply?(channel)
416
546
  channel.instance_variable_get(:@reply_queue).num_waiting > 0
417
547
  end
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.16.0'.freeze
2
+ VERSION = '0.18.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -109,6 +109,7 @@ files:
109
109
  - lib/rails_console_ai/conversation_engine.rb
110
110
  - lib/rails_console_ai/engine.rb
111
111
  - lib/rails_console_ai/executor.rb
112
+ - lib/rails_console_ai/prefixed_io.rb
112
113
  - lib/rails_console_ai/providers/anthropic.rb
113
114
  - lib/rails_console_ai/providers/base.rb
114
115
  - lib/rails_console_ai/providers/bedrock.rb