rails_console_ai 0.15.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: f82dc8df360173a57a1152e60a47e7bf59c31b3721658ee590b7d5cb7c765fbd
4
- data.tar.gz: 942c2df0dda0048784e7cce6911a20685480de5a4c39ae5b1ca0d875d2f6627d
3
+ metadata.gz: 98abad8ed9e3d5d510ffc055352687156ffd4a5c02fbde2610f8ecd46cf2c263
4
+ data.tar.gz: 2979eeb2d6f7e3f8df58a4cd25666307cd4e108dfec379020e3ad6422bbba911
5
5
  SHA512:
6
- metadata.gz: 32edecb4a5c29703ce2ff309fd922c11de57240f98df92072c86f99f3373c3eaeaf689e912960cc60a71cc40e37b2dacf2cea73dd6385cb101f36dd61d913f99
7
- data.tar.gz: e36e885ad95efcf976ee249c2524348e84b11aa61d447d9680ffeb049b7a6f4628c0e34b5027127e04ac83979259b4e16fea3f7208be2c6645d9bf25cf52bdcf
6
+ metadata.gz: bc6695f1321d2c5b39b14e08e676b354f89eb62f29718244a0a25580a5d13250c7581ccfdd52552489f7a53c252433be465fb6b95c9de981825317680f575f5e
7
+ data.tar.gz: 35ff574a1c8404cc43c308f5c5bd13bcc1acda339c9543026a7ac216e4756988629088c436a38ab9dac2a34dc2e4a994168a4f8c70a57fbf7262135db8ac8b7d
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.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
+
14
+ ## [0.16.0]
15
+
16
+ - Run migrations during setup
17
+ - Clarify LLM activity messages in Slack channel
18
+ - Clean up error messages
19
+ - Rotate thinking messages in Slack bot
20
+
5
21
  ## [0.15.0]
6
22
 
7
23
  - Add `config.code_search_paths` to configure searchable code directories
@@ -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
@@ -5,24 +5,6 @@ module RailsConsoleAi
5
5
  class Slack < Base
6
6
  ANSI_REGEX = /\e\[[0-9;]*m/
7
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
8
  def initialize(slack_bot:, channel_id:, thread_ts:, user_name: nil)
27
9
  @slack_bot = slack_bot
28
10
  @channel_id = channel_id
@@ -47,15 +29,22 @@ module RailsConsoleAi
47
29
  end
48
30
 
49
31
  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:/
32
+ raw = strip_ansi(text)
33
+ stripped = raw.strip
34
+
35
+ if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_session:/
36
+ post(stripped)
37
+ elsif stripped =~ /\ACalling LLM/
38
+ # Technical LLM round status — suppress in Slack
39
+ @output_log.write("#{stripped}\n")
40
+ STDOUT.puts "#{@log_prefix} (dim) #{stripped}"
41
+ elsif raw =~ /\A {2,4}\S/ && stripped.length > 10
42
+ # LLM thinking text (2-space indent from conversation engine) — show as status
54
43
  post(stripped)
55
44
  else
56
- # Log for engineers but don't post to Slack
45
+ # Tool result previews (5+ space indent) and other technical noise — log only
57
46
  @output_log.write("#{stripped}\n")
58
- $stdout.puts "#{@log_prefix} (dim) #{stripped}"
47
+ STDOUT.puts "#{@log_prefix} (dim) #{stripped}"
59
48
  end
60
49
  end
61
50
 
@@ -135,6 +124,7 @@ module RailsConsoleAi
135
124
  - The output of `puts` in your code is automatically shown to the user. Do NOT
136
125
  repeat or re-display data that your code already printed via `puts`.
137
126
  Just add a brief summary after (e.g. "10 events found" or "Let me know if you need more detail").
127
+ - Do not offer to make changes or take actions on behalf of the user. Only report findings.
138
128
  - This is a live production database — other processes, users, and background jobs are
139
129
  constantly changing data. Never assume results will be the same as a previous query.
140
130
  Always re-run queries when asked, even if you just ran the same one.
@@ -160,7 +150,7 @@ module RailsConsoleAi
160
150
  def post(text)
161
151
  return if text.nil? || text.strip.empty?
162
152
  @output_log.write("#{text}\n")
163
- $stdout.puts "#{@log_prefix} >> #{text}"
153
+ STDOUT.puts "#{@log_prefix} >> #{text}"
164
154
  @slack_bot.send(:post_message,
165
155
  channel: @channel_id,
166
156
  thread_ts: @thread_ts,
@@ -170,10 +160,6 @@ module RailsConsoleAi
170
160
  RailsConsoleAi.logger.error("Slack post failed: #{e.message}")
171
161
  end
172
162
 
173
- def random_thinking_message
174
- THINKING_MESSAGES.sample
175
- end
176
-
177
163
  def strip_ansi(text)
178
164
  text.to_s.gsub(ANSI_REGEX, '')
179
165
  end
@@ -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
@@ -140,8 +155,8 @@ module RailsConsoleAi
140
155
  return nil
141
156
  end
142
157
  @last_error = "#{e.class}: #{e.message}"
143
- display_error("Error: #{@last_error}")
144
- e.backtrace.first(3).each { |line| display_error(" #{line}") }
158
+ backtrace = e.backtrace.first(3).map { |line| " #{line}" }.join("\n")
159
+ display_error("Error: #{@last_error}\n#{backtrace}")
145
160
  @last_output = captured_output&.string
146
161
  nil
147
162
  end
@@ -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.15.0'.freeze
2
+ VERSION = '0.17.0'.freeze
3
3
  end
@@ -94,7 +94,8 @@ module RailsConsoleAi
94
94
  table = 'rails_console_ai_sessions'
95
95
 
96
96
  if conn.table_exists?(table)
97
- $stdout.puts "\e[32mRailsConsoleAi: #{table} already exists. Run RailsConsoleAi.teardown! first to recreate.\e[0m"
97
+ $stdout.puts "\e[32mRailsConsoleAi: #{table} already exists checking for pending migrations.\e[0m"
98
+ migrate!
98
99
  else
99
100
  conn.create_table(table) do |t|
100
101
  t.text :query, null: false
@@ -151,6 +152,7 @@ module RailsConsoleAi
151
152
  if migrations.empty?
152
153
  $stdout.puts "\e[32mRailsConsoleAi: #{table} is up to date.\e[0m"
153
154
  else
155
+ RailsConsoleAi::Session.reset_column_information if defined?(RailsConsoleAi::Session)
154
156
  $stdout.puts "\e[32mRailsConsoleAi: added columns: #{migrations.join(', ')}.\e[0m"
155
157
  end
156
158
  rescue => e
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.15.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