rails_console_ai 0.23.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: 6f97d0243924a105c21ebd4a86049bef4fdda6ed76d738c1c2850244f8cb6e0e
4
- data.tar.gz: 930e70d14ae27b0d87999b4625f299390bc928646e73417aef2fc5f7f46aaf5f
3
+ metadata.gz: 89e0f9e402a40af4755fe61d2029f10a90c487c1d957e007032dcdd1f54a1c82
4
+ data.tar.gz: 7e68825010aed2bd0f7a07cdcd68a39d54edec2616913ab14c9a10aa972e6d4f
5
5
  SHA512:
6
- metadata.gz: 17d56e01106acbeee8e1299c39e2fbcc3c0f3150922b977652f8857139d8ad7c481c13d080216f492833e22287df616eb4967a6d46162d39b618ac51e74bbeb4
7
- data.tar.gz: 259216bdd4cf23d0b6c8634fad2d48d520eac0b4600d16697cdf176e55a3677a5a7975304697be2f36817bf04aaa0f7317459a7e9d79da591cfe49879398cca3
6
+ metadata.gz: f0efcfa92b22f8cd4df14a643904aba9c0e3c05ac7873de6a303f72fd6cdd4df5c457cf1fdcafb120a02784c7247663e48141500f69214ac5bbfafa945ec9ce0
7
+ data.tar.gz: 2aed178645529500d0b1d72772e19307d4b42de2a0db586f928a46df2a6503d78f3693cde4222006ad0e4227ea840b4bd510a6ef5631e9395466cf9035db679a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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
+
14
+ ## [0.24.0]
15
+
16
+ - Refactor thinking text display and include in Slack with more technical detail
17
+ - Add `a` command to trigger auto-accept mode
18
+ - Include code output in Slack server logs
19
+ - Fix Bedrock issue after declining code execution
20
+ - Fix `allow_code_execution` configuration
21
+
5
22
  ## [0.23.0]
6
23
 
7
24
  - Add `save_skill` tool
@@ -86,7 +86,7 @@ RailsConsoleAi.configure do |config|
86
86
  # config.channels = {
87
87
  # 'slack' => {
88
88
  # 'allowed_usernames' => ['alice', 'bob'], # who can use the bot (or 'ALL')
89
- # 'allow_code_execution' => ['alice'], # who can run code (nil = everyone)
89
+ # 'allow_code_execution' => ['alice'], # who can run code directly via ``` (nil = everyone)
90
90
  # 'pinned_memory_tags' => ['sharding'],
91
91
  # 'bypass_guards_for_methods' => ['ChangeApproval#approve_by!']
92
92
  # },
@@ -2,7 +2,8 @@ module RailsConsoleAi
2
2
  module Channel
3
3
  class Base
4
4
  def display(text); raise NotImplementedError; end
5
- def display_dim(text); raise NotImplementedError; end
5
+ def display_thinking(text); raise NotImplementedError; end
6
+ def display_status(text); raise NotImplementedError; end
6
7
  def display_warning(text); raise NotImplementedError; end
7
8
  def display_error(text); raise NotImplementedError; end
8
9
  def display_code(code); raise NotImplementedError; end
@@ -14,7 +14,11 @@ module RailsConsoleAi
14
14
  $stdout.puts colorize(text, :cyan)
15
15
  end
16
16
 
17
- def display_dim(text)
17
+ def display_thinking(text)
18
+ $stdout.puts "\e[2m#{text}\e[0m"
19
+ end
20
+
21
+ def display_status(text)
18
22
  $stdout.puts "\e[2m#{text}\e[0m"
19
23
  end
20
24
 
@@ -123,8 +127,8 @@ module RailsConsoleAi
123
127
 
124
128
  # --- Omitted output tracking (shared with Executor) ---
125
129
 
126
- MAX_DISPLAY_LINES = 10
127
- MAX_DISPLAY_CHARS = 2000
130
+ MAX_DISPLAY_LINES = 20
131
+ MAX_DISPLAY_CHARS = 4000
128
132
 
129
133
  def init_omitted_tracking
130
134
  @omitted_outputs = {}
@@ -226,7 +230,16 @@ module RailsConsoleAi
226
230
  @interactive_console_capture.write("ai> #{input}\n")
227
231
  @engine.log_interactive_turn
228
232
 
229
- 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
230
243
  if status == :interrupted
231
244
  @engine.pop_last_message
232
245
  @engine.log_interactive_turn
@@ -409,6 +422,7 @@ module RailsConsoleAi
409
422
  @real_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
410
423
  @real_stdout.puts "\e[2m /retry Re-execute the last code block\e[0m"
411
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"
412
426
  @real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
413
427
  end
414
428
 
@@ -28,23 +28,21 @@ module RailsConsoleAi
28
28
  post(strip_ansi(text))
29
29
  end
30
30
 
31
- def display_dim(text)
32
- raw = strip_ansi(text)
33
- stripped = raw.strip
31
+ def display_thinking(text)
32
+ stripped = strip_ansi(text).strip
33
+ return if stripped.empty?
34
+ post(stripped)
35
+ end
36
+
37
+ def display_status(text)
38
+ stripped = strip_ansi(text).strip
39
+ return if stripped.empty?
34
40
 
35
41
  if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_session:/
36
42
  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
43
- post(stripped)
44
43
  else
45
- # Tool result previews (5+ space indent) and other technical noise — log only
46
44
  @output_log.write("#{stripped}\n")
47
- STDOUT.puts "#{@log_prefix} (dim) #{stripped}"
45
+ log_prefixed("(status)", stripped)
48
46
  end
49
47
  end
50
48
 
@@ -58,7 +56,7 @@ module RailsConsoleAi
58
56
 
59
57
  def display_tool_call(text)
60
58
  @output_log.write("-> #{text}\n")
61
- STDOUT.puts "#{@log_prefix} -> #{text}"
59
+ log_prefixed("->", text)
62
60
  end
63
61
 
64
62
  def display_code(code)
@@ -112,9 +110,9 @@ module RailsConsoleAi
112
110
 
113
111
  def system_instructions
114
112
  <<~INSTRUCTIONS.strip
115
- ## Response Formatting (Slack Channel)
113
+ ## Slack Channel
116
114
 
117
- You are responding to non-technical users in Slack. Follow these rules:
115
+ You are responding in a Slack thread.
118
116
 
119
117
  ## Code Execution
120
118
  - ALWAYS use the `execute_code` tool to run Ruby code. Do NOT put code in markdown
@@ -132,11 +130,8 @@ module RailsConsoleAi
132
130
  123 John Smith john@example.com
133
131
  456 Jane Doe jane@example.com
134
132
  ```
135
- - Use `puts` with formatted output instead of returning arrays or hashes
136
- - Summarize findings in plain, simple language
137
- - Do NOT show technical details like SQL queries, token counts, or class names
138
- - Keep explanations simple and jargon-free
139
- - Never return raw Ruby objects — always present data in a human-readable way
133
+ - Use `puts` with formatted output instead of returning arrays or hashes.
134
+ - Never return raw Ruby objects — always present data in a human-readable way.
140
135
  - The output of `puts` in your code is automatically shown to the user. Do NOT
141
136
  repeat or re-display data that your code already printed via `puts`.
142
137
  Just add a brief summary after (e.g. "10 events found" or "Let me know if you need more detail").
@@ -166,7 +161,7 @@ module RailsConsoleAi
166
161
  def post(text)
167
162
  return if text.nil? || text.strip.empty?
168
163
  @output_log.write("#{text}\n")
169
- STDOUT.puts "#{@log_prefix} >> #{text}"
164
+ log_prefixed(">>", text)
170
165
  @slack_bot.send(:post_message,
171
166
  channel: @channel_id,
172
167
  thread_ts: @thread_ts,
@@ -176,6 +171,10 @@ module RailsConsoleAi
176
171
  RailsConsoleAi.logger.error("Slack post failed: #{e.message}")
177
172
  end
178
173
 
174
+ def log_prefixed(tag, text)
175
+ text.each_line { |line| STDOUT.puts "#{@log_prefix} #{tag} #{line.rstrip}" }
176
+ end
177
+
179
178
  def strip_ansi(text)
180
179
  text.to_s.gsub(ANSI_REGEX, '')
181
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
 
@@ -97,7 +97,8 @@ module RailsConsoleAi
97
97
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
98
98
  - For multi-step tasks, use execute_plan to break the work into small, clear steps.
99
99
  - For simple queries, use the execute_code tool.
100
- - Include a brief one-line explanation before or after executing code.
100
+ - Before calling tools, briefly state what you're about to do (e.g., "Let me check the
101
+ user's migration status." or "I'll look up the table structure."). Keep it to one sentence.
101
102
  - Use the app's actual model names, associations, and schema.
102
103
  - Prefer ActiveRecord query interface over raw SQL.
103
104
  - For destructive operations, add a comment warning.
@@ -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
 
@@ -49,7 +49,7 @@ module RailsConsoleAi
49
49
  conversation << { role: :assistant, content: @_last_result_text }
50
50
  conversation << { role: :user, content: error_msg }
51
51
 
52
- @channel.display_dim(" Ran into an issue, trying a different approach...")
52
+ @channel.display_status(" Ran into an issue, trying a different approach...")
53
53
  exec_result, code, executed = one_shot_round(conversation)
54
54
  end
55
55
 
@@ -114,7 +114,7 @@ module RailsConsoleAi
114
114
 
115
115
  status = send_and_execute
116
116
  if status == :error
117
- @channel.display_dim(" Ran into an issue, trying a different approach...")
117
+ @channel.display_status(" Ran into an issue, trying a different approach...")
118
118
  send_and_execute
119
119
  end
120
120
  end
@@ -232,7 +232,7 @@ module RailsConsoleAi
232
232
  @channel.display_warning("No code to retry.")
233
233
  return
234
234
  end
235
- @channel.display_dim(" Retrying last code...")
235
+ @channel.display_status(" Retrying last code...")
236
236
  execute_direct(code)
237
237
  end
238
238
 
@@ -790,19 +790,19 @@ module RailsConsoleAi
790
790
 
791
791
  max_rounds.times do |round|
792
792
  if @channel.cancelled?
793
- @channel.display_dim(" Cancelled.")
793
+ @channel.display_status(" Cancelled.")
794
794
  break
795
795
  end
796
796
 
797
797
  if round == 0
798
- @channel.display_dim(" Thinking...")
798
+ @channel.display_status(" Thinking...")
799
799
  else
800
800
  if last_thinking
801
801
  last_thinking.split("\n").each do |line|
802
- @channel.display_dim(" #{line}")
802
+ @channel.display_thinking(" #{line}")
803
803
  end
804
804
  end
805
- @channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
805
+ @channel.display_status(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
806
806
  end
807
807
 
808
808
  # Trim large tool outputs between rounds to prevent context explosion.
@@ -856,7 +856,7 @@ module RailsConsoleAi
856
856
  else
857
857
  "No matching outputs found with id(s) #{ids.join(', ')}."
858
858
  end
859
- @channel.display_dim(" #{tool_result}")
859
+ @channel.display_status(" #{tool_result}")
860
860
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
861
861
  messages << tool_msg
862
862
  new_messages << tool_msg
@@ -865,7 +865,7 @@ module RailsConsoleAi
865
865
 
866
866
  # Display any pending LLM text before executing the tool
867
867
  if last_thinking
868
- last_thinking.split("\n").each { |line| @channel.display_dim(" #{line}") }
868
+ last_thinking.split("\n").each { |line| @channel.display_thinking(" #{line}") }
869
869
  last_thinking = nil
870
870
  end
871
871
 
@@ -879,7 +879,7 @@ module RailsConsoleAi
879
879
 
880
880
  preview = compact_tool_result(tc[:name], tool_result)
881
881
  cached_tag = tools.last_cached? ? " (cached)" : ""
882
- @channel.display_dim(" #{preview}#{cached_tag}")
882
+ @channel.display_status(" #{preview}#{cached_tag}")
883
883
  end
884
884
 
885
885
  if RailsConsoleAi.configuration.debug
@@ -908,10 +908,10 @@ module RailsConsoleAi
908
908
  tool_call_counts[key] += 1
909
909
 
910
910
  if tool_call_counts[key] >= LOOP_BREAK_THRESHOLD
911
- @channel.display_dim(" Loop detected: #{tc[:name]} called #{tool_call_counts[key]} times with same args — stopping.")
911
+ @channel.display_status(" Loop detected: #{tc[:name]} called #{tool_call_counts[key]} times with same args — stopping.")
912
912
  exhausted = true
913
913
  elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
914
- @channel.display_dim(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
914
+ @channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
915
915
  messages << { role: :user, content: "You are repeating the same tool call (#{tc[:name]}) with the same arguments. This is not making progress. Try a different approach or provide your answer now." }
916
916
  end
917
917
  end
@@ -1095,6 +1095,12 @@ module RailsConsoleAi
1095
1095
  else
1096
1096
  truncate(result, 80)
1097
1097
  end
1098
+ when 'execute_code'
1099
+ lines = result.split("\n").reject { |l| l.strip.empty? }
1100
+ output_lines = lines.select { |l| !l.start_with?('Output:') && !l.start_with?('Return value:') }
1101
+ summary = output_lines.first(2).map { |l| l.strip }.join('; ')
1102
+ summary = truncate(summary, 70) if summary.length > 70
1103
+ "#{output_lines.length} lines: #{summary}"
1098
1104
  when 'execute_plan'
1099
1105
  steps_done = result.scan(/^Step \d+/).length
1100
1106
  steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
@@ -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)
@@ -216,7 +225,20 @@ module RailsConsoleAi
216
225
 
217
226
  loop do
218
227
  case answer
219
- when 'y', 'yes', 'a'
228
+ when 'a', 'auto'
229
+ RailsConsoleAi.configuration.auto_execute = true
230
+ if @channel
231
+ @channel.display_status("Auto-execute: ON")
232
+ else
233
+ $stdout.puts colorize("Auto-execute: ON", :cyan)
234
+ end
235
+ result = execute(code)
236
+ if @last_safety_error
237
+ return nil unless danger_allowed?
238
+ return offer_danger_retry(code)
239
+ end
240
+ return result
241
+ when 'y', 'yes'
220
242
  result = execute(code)
221
243
  if @last_safety_error
222
244
  return nil unless danger_allowed?
@@ -320,6 +342,14 @@ module RailsConsoleAi
320
342
 
321
343
  private
322
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
+
323
353
  def danger_allowed?
324
354
  @channel.nil? || @channel.supports_danger?
325
355
  end
@@ -355,9 +385,9 @@ module RailsConsoleAi
355
385
  def execute_prompt
356
386
  guards = RailsConsoleAi.configuration.safety_guards
357
387
  if !guards.empty? && guards.enabled? && danger_allowed?
358
- "Execute? [y/N/danger] "
388
+ "Execute? [y/N/a/danger] "
359
389
  else
360
- "Execute? [y/N] "
390
+ "Execute? [y/N/a] "
361
391
  end
362
392
  end
363
393
 
@@ -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
 
@@ -121,10 +121,10 @@ module RailsConsoleAi
121
121
  else
122
122
  [{ text: msg[:content].to_s }]
123
123
  end
124
- # Bedrock rejects empty text blocks in content arrays
125
- content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.empty? }
124
+ # Bedrock rejects empty or whitespace-only text blocks in content arrays
125
+ content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.strip.empty? }
126
126
  # Bedrock also rejects messages with completely empty content arrays
127
- content << { text: ' ' } if content.empty?
127
+ content << { text: '.' } if content.empty?
128
128
  { role: msg[:role].to_s, content: content }
129
129
  end
130
130
 
@@ -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])
@@ -443,10 +453,10 @@ module RailsConsoleAi
443
453
  Thread.current.report_on_exception = false
444
454
  Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
445
455
  begin
446
- channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
456
+ channel.display_status("_session: #{channel_id}/#{thread_ts}_")
447
457
  if restored
448
458
  puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
449
- channel.display_dim("_(session restored — continuing from previous conversation)_")
459
+ channel.display_status("_(session restored — continuing from previous conversation)_")
450
460
  end
451
461
  engine.process_message(text)
452
462
  rescue => e
@@ -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
@@ -368,13 +368,6 @@ module RailsConsoleAi
368
368
  def register_execute_plan
369
369
  return unless @executor
370
370
 
371
- # Check per-channel code execution permission
372
- if @channel
373
- unless RailsConsoleAi.configuration.username_allowed?(@channel.mode, 'allow_code_execution', @channel.user_identity)
374
- return
375
- end
376
- end
377
-
378
371
  register(
379
372
  name: 'execute_code',
380
373
  description: 'Execute Ruby code in the Rails console and return the result. Use this for all code execution — simple queries, data lookups, reports, etc. The output of puts/print statements is automatically shown to the user. The return value is sent back to you so you can summarize the findings.',
@@ -419,12 +412,8 @@ module RailsConsoleAi
419
412
  # Show the code to the user
420
413
  @executor.display_code_block(code)
421
414
 
422
- # Slack: execute directly, suppress display (output goes back to LLM as tool result).
423
- # Console: show code and confirm before executing, display output directly.
424
- exec_result = if @channel&.mode == 'slack'
425
- @executor.execute(code, display: false)
426
- elsif RailsConsoleAi.configuration.auto_execute
427
- @executor.execute(code, display: false)
415
+ exec_result = if @channel&.mode == 'slack' || RailsConsoleAi.configuration.auto_execute
416
+ @executor.execute(code)
428
417
  else
429
418
  @executor.confirm_and_execute(code)
430
419
  end
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.23.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.23.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr