rails_console_ai 0.24.0 → 0.25.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 +13 -3
- data/lib/rails_console_ai/channel/slack.rb +7 -3
- data/lib/rails_console_ai/configuration.rb +1 -1
- data/lib/rails_console_ai/conversation_engine.rb +2 -2
- data/lib/rails_console_ai/executor.rb +28 -11
- data/lib/rails_console_ai/prefixed_io.rb +9 -0
- data/lib/rails_console_ai/safety_guards.rb +33 -14
- data/lib/rails_console_ai/slack_bot.rb +23 -13
- data/lib/rails_console_ai/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89e0f9e402a40af4755fe61d2029f10a90c487c1d957e007032dcdd1f54a1c82
|
|
4
|
+
data.tar.gz: 7e68825010aed2bd0f7a07cdcd68a39d54edec2616913ab14c9a10aa972e6d4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0efcfa92b22f8cd4df14a643904aba9c0e3c05ac7873de6a303f72fd6cdd4df5c457cf1fdcafb120a02784c7247663e48141500f69214ac5bbfafa945ec9ce0
|
|
7
|
+
data.tar.gz: 2aed178645529500d0b1d72772e19307d4b42de2a0db586f928a46df2a6503d78f3693cde4222006ad0e4227ea840b4bd510a6ef5631e9395466cf9035db679a
|
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.25.0]
|
|
6
|
+
|
|
7
|
+
- Expand truncation limits
|
|
8
|
+
- Allow any user on allow list to interact with Slack bot in a thread
|
|
9
|
+
- Handle ctrl-c better in console
|
|
10
|
+
- Fix stdout capture in Slack sessions
|
|
11
|
+
- Improve Slack bot logging
|
|
12
|
+
- Fix thread safety issues in Slack bot
|
|
13
|
+
|
|
5
14
|
## [0.24.0]
|
|
6
15
|
|
|
7
16
|
- Refactor thinking text display and include in Slack with more technical detail
|
|
@@ -127,8 +127,8 @@ module RailsConsoleAi
|
|
|
127
127
|
|
|
128
128
|
# --- Omitted output tracking (shared with Executor) ---
|
|
129
129
|
|
|
130
|
-
MAX_DISPLAY_LINES =
|
|
131
|
-
MAX_DISPLAY_CHARS =
|
|
130
|
+
MAX_DISPLAY_LINES = 20
|
|
131
|
+
MAX_DISPLAY_CHARS = 4000
|
|
132
132
|
|
|
133
133
|
def init_omitted_tracking
|
|
134
134
|
@omitted_outputs = {}
|
|
@@ -230,7 +230,16 @@ module RailsConsoleAi
|
|
|
230
230
|
@interactive_console_capture.write("ai> #{input}\n")
|
|
231
231
|
@engine.log_interactive_turn
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
expected_stdout = $stdout
|
|
234
|
+
begin
|
|
235
|
+
status = @engine.send_and_execute
|
|
236
|
+
rescue Interrupt
|
|
237
|
+
$stdout = expected_stdout
|
|
238
|
+
$stdout.puts "\n\e[33m Cancelled.\e[0m"
|
|
239
|
+
@engine.pop_last_message
|
|
240
|
+
@engine.log_interactive_turn
|
|
241
|
+
next
|
|
242
|
+
end
|
|
234
243
|
if status == :interrupted
|
|
235
244
|
@engine.pop_last_message
|
|
236
245
|
@engine.log_interactive_turn
|
|
@@ -413,6 +422,7 @@ module RailsConsoleAi
|
|
|
413
422
|
@real_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
|
|
414
423
|
@real_stdout.puts "\e[2m /retry Re-execute the last code block\e[0m"
|
|
415
424
|
@real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
|
|
425
|
+
@real_stdout.puts "\e[2m Ctrl-C Cancel the current operation\e[0m"
|
|
416
426
|
@real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
|
|
417
427
|
end
|
|
418
428
|
|
|
@@ -42,7 +42,7 @@ module RailsConsoleAi
|
|
|
42
42
|
post(stripped)
|
|
43
43
|
else
|
|
44
44
|
@output_log.write("#{stripped}\n")
|
|
45
|
-
|
|
45
|
+
log_prefixed("(status)", stripped)
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -56,7 +56,7 @@ module RailsConsoleAi
|
|
|
56
56
|
|
|
57
57
|
def display_tool_call(text)
|
|
58
58
|
@output_log.write("-> #{text}\n")
|
|
59
|
-
|
|
59
|
+
log_prefixed("->", text)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def display_code(code)
|
|
@@ -161,7 +161,7 @@ module RailsConsoleAi
|
|
|
161
161
|
def post(text)
|
|
162
162
|
return if text.nil? || text.strip.empty?
|
|
163
163
|
@output_log.write("#{text}\n")
|
|
164
|
-
|
|
164
|
+
log_prefixed(">>", text)
|
|
165
165
|
@slack_bot.send(:post_message,
|
|
166
166
|
channel: @channel_id,
|
|
167
167
|
thread_ts: @thread_ts,
|
|
@@ -171,6 +171,10 @@ module RailsConsoleAi
|
|
|
171
171
|
RailsConsoleAi.logger.error("Slack post failed: #{e.message}")
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
+
def log_prefixed(tag, text)
|
|
175
|
+
text.each_line { |line| STDOUT.puts "#{@log_prefix} #{tag} #{line.rstrip}" }
|
|
176
|
+
end
|
|
177
|
+
|
|
174
178
|
def strip_ansi(text)
|
|
175
179
|
text.to_s.gsub(ANSI_REGEX, '')
|
|
176
180
|
end
|
|
@@ -3,8 +3,8 @@ module RailsConsoleAi
|
|
|
3
3
|
attr_reader :history, :total_input_tokens, :total_output_tokens,
|
|
4
4
|
:interactive_session_id, :session_name
|
|
5
5
|
|
|
6
|
-
LARGE_OUTPUT_THRESHOLD =
|
|
7
|
-
LARGE_OUTPUT_PREVIEW_CHARS =
|
|
6
|
+
LARGE_OUTPUT_THRESHOLD = 20_000 # chars — truncate tool results larger than this immediately
|
|
7
|
+
LARGE_OUTPUT_PREVIEW_CHARS = 16_000 # chars — how much of the output the LLM sees upfront
|
|
8
8
|
LOOP_WARN_THRESHOLD = 3 # same tool+args repeated → inject warning
|
|
9
9
|
LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
|
|
10
10
|
|
|
@@ -100,13 +100,18 @@ module RailsConsoleAi
|
|
|
100
100
|
@last_safety_exception = nil
|
|
101
101
|
captured_output = StringIO.new
|
|
102
102
|
old_stdout = $stdout
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
# Three capture strategies:
|
|
104
|
+
# 1. Slack mode (PrefixedIO active): thread-local capture to avoid cross-thread pollution
|
|
105
|
+
# 2. Console mode (channel present): capture-only, channel.display_result_output shows it after
|
|
106
|
+
# 3. No channel (tests/one-shot): TeeIO so output appears live AND is captured
|
|
107
|
+
use_thread_local = defined?(RailsConsoleAi::PrefixedIO) && $stdout.is_a?(RailsConsoleAi::PrefixedIO)
|
|
108
|
+
if use_thread_local
|
|
109
|
+
Thread.current[:capture_io] = captured_output
|
|
110
|
+
elsif @channel
|
|
111
|
+
$stdout = captured_output
|
|
112
|
+
else
|
|
113
|
+
$stdout = TeeIO.new(old_stdout, captured_output)
|
|
114
|
+
end
|
|
110
115
|
|
|
111
116
|
RailsConsoleAi::SafetyError.clear!
|
|
112
117
|
|
|
@@ -114,7 +119,7 @@ module RailsConsoleAi
|
|
|
114
119
|
binding_context.eval(code, "(rails_console_ai)", 1)
|
|
115
120
|
end
|
|
116
121
|
|
|
117
|
-
|
|
122
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
118
123
|
|
|
119
124
|
# Check if a SafetyError was raised but swallowed by a rescue inside the eval'd code
|
|
120
125
|
if (swallowed = RailsConsoleAi::SafetyError.last_raised)
|
|
@@ -136,8 +141,12 @@ module RailsConsoleAi
|
|
|
136
141
|
|
|
137
142
|
@last_output = captured_output.string
|
|
138
143
|
result
|
|
144
|
+
rescue Interrupt
|
|
145
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
146
|
+
@last_output = captured_output&.string
|
|
147
|
+
raise
|
|
139
148
|
rescue RailsConsoleAi::SafetyError => e
|
|
140
|
-
|
|
149
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
141
150
|
RailsConsoleAi::SafetyError.clear!
|
|
142
151
|
@last_error = "SafetyError: #{e.message}"
|
|
143
152
|
@last_safety_error = true
|
|
@@ -146,13 +155,13 @@ module RailsConsoleAi
|
|
|
146
155
|
@last_output = captured_output&.string
|
|
147
156
|
nil
|
|
148
157
|
rescue SyntaxError => e
|
|
149
|
-
|
|
158
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
150
159
|
@last_error = "SyntaxError: #{e.message}"
|
|
151
160
|
log_execution_error(@last_error)
|
|
152
161
|
@last_output = nil
|
|
153
162
|
nil
|
|
154
163
|
rescue => e
|
|
155
|
-
|
|
164
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
156
165
|
# Check if a SafetyError is wrapped (e.g. ActiveRecord::StatementInvalid wrapping our error)
|
|
157
166
|
if safety_error?(e)
|
|
158
167
|
safety_exc = extract_safety_exception(e)
|
|
@@ -333,6 +342,14 @@ module RailsConsoleAi
|
|
|
333
342
|
|
|
334
343
|
private
|
|
335
344
|
|
|
345
|
+
def restore_stdout(use_thread_local, old_stdout)
|
|
346
|
+
if use_thread_local
|
|
347
|
+
Thread.current[:capture_io] = nil
|
|
348
|
+
else
|
|
349
|
+
$stdout = old_stdout if old_stdout
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
336
353
|
def danger_allowed?
|
|
337
354
|
@channel.nil? || @channel.supports_danger?
|
|
338
355
|
end
|
|
@@ -5,6 +5,9 @@ module RailsConsoleAi
|
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def write(str)
|
|
8
|
+
if (capture = Thread.current[:capture_io])
|
|
9
|
+
return capture.write(str)
|
|
10
|
+
end
|
|
8
11
|
prefix = Thread.current[:log_prefix]
|
|
9
12
|
if prefix && str.is_a?(String) && !str.strip.empty?
|
|
10
13
|
prefixed = str.gsub(/^(?=.)/, "#{prefix} ")
|
|
@@ -15,6 +18,9 @@ module RailsConsoleAi
|
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def puts(*args)
|
|
21
|
+
if (capture = Thread.current[:capture_io])
|
|
22
|
+
return capture.puts(*args)
|
|
23
|
+
end
|
|
18
24
|
prefix = Thread.current[:log_prefix]
|
|
19
25
|
if prefix
|
|
20
26
|
args = [""] if args.empty?
|
|
@@ -32,6 +38,9 @@ module RailsConsoleAi
|
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def print(*args)
|
|
41
|
+
if (capture = Thread.current[:capture_io])
|
|
42
|
+
return capture.print(*args)
|
|
43
|
+
end
|
|
35
44
|
@io.print(*args)
|
|
36
45
|
end
|
|
37
46
|
|
|
@@ -41,15 +41,15 @@ module RailsConsoleAi
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def enabled?
|
|
44
|
-
@enabled
|
|
44
|
+
@enabled && !Thread.current[:rails_console_ai_guards_disabled]
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def enable!
|
|
48
|
-
|
|
48
|
+
Thread.current[:rails_console_ai_guards_disabled] = nil
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def disable!
|
|
52
|
-
|
|
52
|
+
Thread.current[:rails_console_ai_guards_disabled] = true
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def empty?
|
|
@@ -60,33 +60,52 @@ module RailsConsoleAi
|
|
|
60
60
|
@guards.keys
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
# Add a permanent (config-time) allowlist entry visible to all threads.
|
|
64
|
+
def allow_global(guard_name, key)
|
|
64
65
|
guard_name = guard_name.to_sym
|
|
65
66
|
@allowlist[guard_name] ||= []
|
|
66
67
|
@allowlist[guard_name] << key unless @allowlist[guard_name].include?(key)
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
# Add a thread-local allowlist entry (runtime "allow for this session").
|
|
71
|
+
def allow(guard_name, key)
|
|
72
|
+
thread_list = Thread.current[:rails_console_ai_allowlist] ||= {}
|
|
73
|
+
guard_name = guard_name.to_sym
|
|
74
|
+
thread_list[guard_name] ||= []
|
|
75
|
+
thread_list[guard_name] << key unless thread_list[guard_name].include?(key)
|
|
76
|
+
end
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
def allowed?(guard_name, key)
|
|
79
|
+
guard_name = guard_name.to_sym
|
|
80
|
+
match = ->(entries) {
|
|
81
|
+
entries&.any? do |entry|
|
|
82
|
+
case entry
|
|
83
|
+
when Regexp then key.match?(entry)
|
|
84
|
+
else entry.to_s == key.to_s
|
|
85
|
+
end
|
|
77
86
|
end
|
|
78
|
-
|
|
87
|
+
}
|
|
88
|
+
# Check global (config-time) allowlist
|
|
89
|
+
return true if match.call(@allowlist[guard_name])
|
|
90
|
+
# Check thread-local (runtime session) allowlist
|
|
91
|
+
thread_list = Thread.current[:rails_console_ai_allowlist]
|
|
92
|
+
return true if thread_list && match.call(thread_list[guard_name])
|
|
93
|
+
false
|
|
79
94
|
end
|
|
80
95
|
|
|
81
96
|
def allowlist
|
|
82
|
-
|
|
97
|
+
thread_list = Thread.current[:rails_console_ai_allowlist]
|
|
98
|
+
return @allowlist unless thread_list
|
|
99
|
+
merged = @allowlist.dup
|
|
100
|
+
thread_list.each { |k, v| merged[k] = (merged[k] || []) + v }
|
|
101
|
+
merged
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
# Compose all guards around a block of code.
|
|
86
105
|
# Each guard is an around-block: guard.call { inner }
|
|
87
106
|
# Result: guard_1 { guard_2 { guard_3 { yield } } }
|
|
88
107
|
def wrap(channel_mode: nil, additional_bypass_methods: nil, &block)
|
|
89
|
-
return yield unless
|
|
108
|
+
return yield unless enabled? && !@guards.empty?
|
|
90
109
|
|
|
91
110
|
install_skills_once!
|
|
92
111
|
bypass_set = resolve_bypass_methods(channel_mode)
|
|
@@ -281,6 +281,19 @@ module RailsConsoleAi
|
|
|
281
281
|
slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
|
|
282
282
|
end
|
|
283
283
|
|
|
284
|
+
# Cancel any in-progress thread on this session before spawning a new one.
|
|
285
|
+
# Prevents concurrent engine access when users send messages faster than
|
|
286
|
+
# the LLM can respond.
|
|
287
|
+
def replace_session_thread(session, &block)
|
|
288
|
+
old_thread = session[:thread]
|
|
289
|
+
if old_thread&.alive?
|
|
290
|
+
session[:channel].cancel!
|
|
291
|
+
old_thread.join(5) # wait up to 5s for graceful shutdown
|
|
292
|
+
session[:channel].instance_variable_set(:@cancelled, false)
|
|
293
|
+
end
|
|
294
|
+
session[:thread] = Thread.new(&block)
|
|
295
|
+
end
|
|
296
|
+
|
|
284
297
|
# --- Event handling ---
|
|
285
298
|
|
|
286
299
|
def handle_event(msg)
|
|
@@ -314,17 +327,14 @@ module RailsConsoleAi
|
|
|
314
327
|
|
|
315
328
|
session = @mutex.synchronize { @sessions[thread_ts] }
|
|
316
329
|
if session
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
chk_name
|
|
321
|
-
|
|
322
|
-
puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{chk_name} << (ignored — not in allowed usernames)"
|
|
323
|
-
post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{chk_name}). Ask an admin to add you to the allowed usernames list.")
|
|
324
|
-
end
|
|
330
|
+
# Any allowed user can interact with an existing session
|
|
331
|
+
chk_name = resolve_user_name(user_id)
|
|
332
|
+
unless RailsConsoleAi.configuration.username_allowed?('slack', 'allowed_usernames', chk_name)
|
|
333
|
+
puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{chk_name} << (ignored — not in allowed usernames)"
|
|
334
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{chk_name}). Ask an admin to add you to the allowed usernames list.")
|
|
325
335
|
return
|
|
326
336
|
end
|
|
327
|
-
#
|
|
337
|
+
# Must @mention unless bot asked a question (waiting_for_reply?)
|
|
328
338
|
return unless mentioned || waiting_for_reply?(session[:channel])
|
|
329
339
|
# Log thread messages since last mention
|
|
330
340
|
thread_msgs = fetch_thread_messages(channel_id, thread_ts, since_ts: session[:last_seen_ts], exclude_ts: event[:ts])
|
|
@@ -482,7 +492,7 @@ module RailsConsoleAi
|
|
|
482
492
|
end
|
|
483
493
|
|
|
484
494
|
# Otherwise treat as a new message in the conversation
|
|
485
|
-
session
|
|
495
|
+
replace_session_thread(session) do
|
|
486
496
|
Thread.current.report_on_exception = false
|
|
487
497
|
Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
|
|
488
498
|
begin
|
|
@@ -514,7 +524,7 @@ module RailsConsoleAi
|
|
|
514
524
|
channel = session[:channel]
|
|
515
525
|
engine = session[:engine]
|
|
516
526
|
|
|
517
|
-
session
|
|
527
|
+
replace_session_thread(session) do
|
|
518
528
|
Thread.current.report_on_exception = false
|
|
519
529
|
Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
|
|
520
530
|
begin
|
|
@@ -614,7 +624,7 @@ module RailsConsoleAi
|
|
|
614
624
|
summary = bang_cost(engine)
|
|
615
625
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
616
626
|
when 'compact'
|
|
617
|
-
session
|
|
627
|
+
replace_session_thread(session) do
|
|
618
628
|
Thread.current.report_on_exception = false
|
|
619
629
|
begin
|
|
620
630
|
before = engine.history.length
|
|
@@ -646,7 +656,7 @@ module RailsConsoleAi
|
|
|
646
656
|
summary = bang_context(engine)
|
|
647
657
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
648
658
|
when 'retry'
|
|
649
|
-
session
|
|
659
|
+
replace_session_thread(session) do
|
|
650
660
|
Thread.current.report_on_exception = false
|
|
651
661
|
Thread.current[:log_prefix] = session[:channel].instance_variable_get(:@log_prefix)
|
|
652
662
|
begin
|