rails_console_ai 0.16.0 → 0.17.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: 98abad8ed9e3d5d510ffc055352687156ffd4a5c02fbde2610f8ecd46cf2c263
4
+ data.tar.gz: 2979eeb2d6f7e3f8df58a4cd25666307cd4e108dfec379020e3ad6422bbba911
5
5
  SHA512:
6
- metadata.gz: ba68a62e707d3b35f2fc0f7f24d9d73d930c951936f37f8ec60c34dce7975bf67e184bf05fd418db1773c9ec7d58455cf25c122477ec67bde8fcee47247fcb25
7
- data.tar.gz: 3fa2a64467e090b52ae68b21f93e9a1cc15f03b445f8f9a02ec0ca51d74514211f6a4eeff551ec91858adccb7c59e1a46218191480c7e749c28538c665897287
6
+ metadata.gz: bc6695f1321d2c5b39b14e08e676b354f89eb62f29718244a0a25580a5d13250c7581ccfdd52552489f7a53c252433be465fb6b95c9de981825317680f575f5e
7
+ data.tar.gz: 35ff574a1c8404cc43c308f5c5bd13bcc1acda339c9543026a7ac216e4756988629088c436a38ab9dac2a34dc2e4a994168a4f8c70a57fbf7262135db8ac8b7d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.17.0]
6
+
7
+ - Add `/retry` command
8
+ - Print provider information when `ai!` starts
9
+ - Keep Slack bot alive during long-running sessions
10
+ - Improve Slack bot log prefixes for production log search
11
+ - Catch safety errors even when swallowed by executed code
12
+ - Fix Bedrock handling of multiple tool results
13
+
5
14
  ## [0.16.0]
6
15
 
7
16
  - 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,11 @@ 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
+
29
38
  @bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
30
39
  log_startup
31
40
 
@@ -78,9 +87,27 @@ module RailsConsoleAi
78
87
 
79
88
  puts "Connected to Slack Socket Mode."
80
89
 
81
- # Main read loop
90
+ # Main read loop with keepalive
91
+ last_activity = Time.now
92
+ ping_sent = false
93
+
82
94
  loop do
95
+ ready = IO.select([ssl.to_io], nil, nil, PING_INTERVAL)
96
+
97
+ if ready.nil?
98
+ # Timeout — no data received
99
+ if ping_sent && (Time.now - last_activity) > PONG_TIMEOUT
100
+ puts "Slack connection timed out (no pong received). Reconnecting..."
101
+ break
102
+ end
103
+ send_ws_ping(ssl)
104
+ ping_sent = true
105
+ next
106
+ end
107
+
83
108
  data = read_ws_frame(ssl)
109
+ last_activity = Time.now
110
+ ping_sent = false
84
111
  next unless data
85
112
 
86
113
  begin
@@ -130,6 +157,11 @@ module RailsConsoleAi
130
157
  send_ws_pong(ssl, payload)
131
158
  return nil
132
159
  end
160
+ # Handle pong (opcode 10) — response to our keepalive ping
161
+ if opcode == 0xA
162
+ read_ws_payload(ssl) # consume payload
163
+ return nil
164
+ end
133
165
  # Close frame (opcode 8)
134
166
  return nil if opcode == 8
135
167
  # Only process text frames (opcode 1)
@@ -194,6 +226,14 @@ module RailsConsoleAi
194
226
  ssl.write(frame)
195
227
  end
196
228
 
229
+ def send_ws_ping(ssl)
230
+ mask_key = 4.times.map { rand(256) }
231
+ frame = [0x89].pack("C") # FIN + ping opcode
232
+ frame << [0x80].pack("C") # masked, zero-length payload
233
+ frame << mask_key.pack("C*")
234
+ ssl.write(frame)
235
+ end
236
+
197
237
  # --- Slack Web API (minimal, uses Net::HTTP) ---
198
238
 
199
239
  def slack_api(method, token: @bot_token, **params)
@@ -309,6 +349,7 @@ module RailsConsoleAi
309
349
 
310
350
  session[:thread] = Thread.new do
311
351
  Thread.current.report_on_exception = false
352
+ Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}] @#{user_name}"
312
353
  begin
313
354
  channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
314
355
  if restored
@@ -351,6 +392,7 @@ module RailsConsoleAi
351
392
  # Otherwise treat as a new message in the conversation
352
393
  session[:thread] = Thread.new do
353
394
  Thread.current.report_on_exception = false
395
+ Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
354
396
  begin
355
397
  engine.process_message(text)
356
398
  rescue => e
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.16.0'.freeze
2
+ VERSION = '0.17.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.17.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