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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41b94c2b3a01cba1210242c6c8752510fc1976bf9b7195272f4a109dceadcdab
4
- data.tar.gz: 6c813abfeeda804b115dfd2a385bc67e1796b0af80cc438e5f41d66886fa519e
3
+ metadata.gz: 89e0f9e402a40af4755fe61d2029f10a90c487c1d957e007032dcdd1f54a1c82
4
+ data.tar.gz: 7e68825010aed2bd0f7a07cdcd68a39d54edec2616913ab14c9a10aa972e6d4f
5
5
  SHA512:
6
- metadata.gz: 52281835764610528b039d12ac786ec7056d035e9055d5574bee3a3143c9833e8fa5943a5932980c7ee28ae77351a178186dc15b3afb2f0e9acb7c71b1104c5f
7
- data.tar.gz: d1f56cd511ff3411b6e2783c0790bbc3d012a420971fe2cd400f7f573ffc168f5b6bca5c692618bf5c4ef2eaa9d0d0283958afcf1b7ac71d426dd4f2f4377425
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 = 10
131
- MAX_DISPLAY_CHARS = 2000
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
- status = @engine.send_and_execute
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
- STDOUT.puts "#{@log_prefix} (status) #{stripped}"
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
- STDOUT.puts "#{@log_prefix} -> #{text}"
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
- STDOUT.puts "#{@log_prefix} >> #{text}"
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
@@ -135,7 +135,7 @@ module RailsConsoleAi
135
135
  end
136
136
 
137
137
  if allow
138
- Array(allow).each { |key| safety_guards.allow(guard_name, key) }
138
+ Array(allow).each { |key| safety_guards.allow_global(guard_name, key) }
139
139
  end
140
140
  end
141
141
 
@@ -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 = 10_000 # chars — truncate tool results larger than this immediately
7
- LARGE_OUTPUT_PREVIEW_CHARS = 8_000 # chars — how much of the output the LLM sees upfront
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
- # When a channel is present it handles display (with truncation), so capture only.
104
- # Without a channel, tee so output appears live on the terminal.
105
- $stdout = if @channel
106
- captured_output
107
- else
108
- TeeIO.new(old_stdout, captured_output)
109
- end
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
- $stdout = old_stdout
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
- $stdout = old_stdout if old_stdout
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
- $stdout = old_stdout if old_stdout
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
- $stdout = old_stdout if old_stdout
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
- @enabled = true
48
+ Thread.current[:rails_console_ai_guards_disabled] = nil
49
49
  end
50
50
 
51
51
  def disable!
52
- @enabled = false
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
- def allow(guard_name, key)
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
- def allowed?(guard_name, key)
70
- entries = @allowlist[guard_name.to_sym]
71
- return false unless entries
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
- entries.any? do |entry|
74
- case entry
75
- when Regexp then key.match?(entry)
76
- else entry.to_s == key.to_s
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
- end
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
- @allowlist
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 @enabled && !@guards.empty?
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
- # Enforce session ownership
318
- unless session[:owner_user_id] == user_id
319
- # Non-owner: tell unrecognized users, silently ignore recognized non-owners
320
- chk_name = resolve_user_name(user_id)
321
- unless RailsConsoleAi.configuration.username_allowed?('slack', 'allowed_usernames', chk_name)
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
- # Owner must @mention unless bot asked a question (waiting_for_reply?)
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[:thread] = Thread.new do
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[:thread] = Thread.new do
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[:thread] = Thread.new do
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[:thread] = Thread.new do
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
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.24.0'.freeze
2
+ VERSION = '0.25.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.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr