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 +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/rails_console_ai/channel/console.rb +9 -0
- data/lib/rails_console_ai/channel/slack.rb +3 -3
- data/lib/rails_console_ai/conversation_engine.rb +10 -0
- data/lib/rails_console_ai/executor.rb +15 -0
- data/lib/rails_console_ai/prefixed_io.rb +50 -0
- data/lib/rails_console_ai/providers/bedrock.rb +13 -1
- data/lib/rails_console_ai/safety_guards.rb +11 -0
- data/lib/rails_console_ai/slack_bot.rb +43 -1
- data/lib/rails_console_ai/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98abad8ed9e3d5d510ffc055352687156ffd4a5c02fbde2610f8ecd46cf2c263
|
|
4
|
+
data.tar.gz: 2979eeb2d6f7e3f8df58a4cd25666307cd4e108dfec379020e3ad6422bbba911
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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
|