rails_console_ai 0.20.0 → 0.21.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: ee0daa4b9926c6df19c9e2946f019b5d17dd3bd64d4e8998e92d86ed16dff8e1
4
- data.tar.gz: f336fe3abf34a513a5de12b138d69f4941e3cd65eeecaa8b548658d3da8b846e
3
+ metadata.gz: 8faec693c3d3c83dafcaa65437081d5ba8e235f04527a28182104c4ecba5b80d
4
+ data.tar.gz: 171445a3ddc0c50093b1688260545df1558548a29cb81a6d3262d15d8ec4cf3c
5
5
  SHA512:
6
- metadata.gz: 35d266cac783a3e43a6712380085215b7ba7503874b4f7a97ce6fac30875bded29e658d52dc4d1196bcb2e907e555255538ec6563e190212ea0bbcc6c02179d4
7
- data.tar.gz: 64e365d8d18a9a559bf9c8ec5b982f7ecff03d1faed9d731bc3734b6493cdeb7cab3055de5ae56da3e5d5112ccfb7b6ecf47b652a7a570c8fdb8401e0eddcf40
6
+ metadata.gz: 1d0689ebfc82073deb58f8bd03fce39503541d63f042f531322bfc11ef0d6cf65f69fa9ae59c8b89a8a549ea9b4b7893e8505da83887ec4cbc5c1a064ffa5d99
7
+ data.tar.gz: e8f635c26035fcf73f2ccda5bd3d403a2ec33ef806c701c28f67ee8888a8189625a2569f45c369f7b29189fda3b162f03067be26590afbe5428be9da15487552
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.21.0]
6
+
7
+ - Add Slack @mention support and channel name tracking
8
+ - Stop looping after user cancels execution
9
+ - Remove edit feature from executor
10
+ - Include more info in tool call log line
11
+ - Support class methods in `bypass_guards_for_methods`
12
+ - Rename setup tasks to `ai_db_setup` and `ai_db_migrate`
13
+ - Fix effective model resolution in multi-threaded Slack bot
14
+ - Fix cost tracking with prompt caching through Bedrock
15
+ - Add `/unthink` command
16
+ - Make `!think` / `/think` thread-safe for Slack
17
+ - Fix truncating console output
18
+ - Reduce cost by deferring large output until LLM requests it
19
+ - Add `!name`, `!model`, and `/model` commands to Slack and console
20
+
5
21
  ## [0.20.0]
6
22
 
7
23
  - Add per-user system prompt seeding
data/README.md CHANGED
@@ -12,7 +12,7 @@ irb> ai "find the 5 most recent orders over $100"
12
12
 
13
13
  Order.where("total > ?", 100).order(created_at: :desc).limit(5)
14
14
 
15
- Execute? [y/N/edit/danger] y
15
+ Execute? [y/N/danger] y
16
16
  => [#<Order id: 4821, ...>, ...]
17
17
  ```
18
18
 
@@ -62,7 +62,8 @@ end
62
62
  | `ai!` | Enter interactive mode (multi-turn conversation) |
63
63
  | `ai? "query"` | Explain only, no execution |
64
64
  | `ai_init` | Generate app guide for better AI context |
65
- | `ai_setup` | Install session logging table |
65
+ | `ai_db_setup` | Install session logging table + run migrations |
66
+ | `ai_db_migrate` | Run pending session table migrations |
66
67
  | `ai_sessions` | List recent sessions |
67
68
  | `ai_resume` | Resume a session by name or ID |
68
69
  | `ai_memories` | Show stored memories |
@@ -100,7 +101,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
100
101
  - **Skills** — predefined procedures with guard bypasses that the AI activates on demand
101
102
  - **Memories** — AI saves what it learns about your app across sessions
102
103
  - **App guide** — `ai_init` generates a guide injected into every system prompt
103
- - **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
104
+ - **Sessions** — name, list, and resume interactive conversations (`ai_db_setup` to enable)
104
105
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
105
106
  - **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
106
107
  - **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
@@ -213,7 +214,7 @@ Skills and global `bypass_guards_for_methods` coexist — use config-level bypas
213
214
  ### Toggling Safe Mode
214
215
 
215
216
  - **`/danger`** in interactive mode toggles all guards off/on for the session
216
- - **`d`** at the `Execute? [y/N/edit/danger]` prompt disables guards for that single execution
217
+ - **`d`** at the `Execute? [y/N/danger]` prompt disables guards for that single execution
217
218
  - When a guard blocks an operation, the user is prompted: `Re-run with safe mode disabled? [y/N]`
218
219
 
219
220
  ## LLM Providers
@@ -296,7 +297,7 @@ Timeout is automatically raised to 300s minimum for local models to account for
296
297
  RailsConsoleAi.configure do |config|
297
298
  config.provider = :anthropic # :anthropic, :openai, :bedrock, :local
298
299
  config.auto_execute = false # true to skip confirmations
299
- config.session_logging = true # requires ai_setup
300
+ config.session_logging = true # requires ai_db_setup
300
301
  config.temperature = 0.2
301
302
  config.timeout = 30 # HTTP timeout in seconds
302
303
  config.max_tool_rounds = 200 # safety cap on tool-use loops
@@ -363,11 +364,14 @@ Run RailsConsoleAi as a Slack bot. Each Slack thread becomes an independent AI s
363
364
  3. **Bot Token Scopes** — OAuth & Permissions → Bot Token Scopes, add:
364
365
  - `chat:write`
365
366
  - `channels:history` (public channels)
367
+ - `channels:read` (channel names in logs, optional)
366
368
  - `groups:history` (private channels, optional)
369
+ - `groups:read` (private channel names in logs, optional)
367
370
  - `im:history` (direct messages)
368
371
  - `users:read`
369
372
 
370
373
  4. **Event Subscriptions** — Event Subscriptions → toggle ON, then under "Subscribe to bot events" add:
374
+ - `app_mention` (respond when @mentioned in any channel)
371
375
  - `message.channels` (public channels)
372
376
  - `message.groups` (private channels, optional)
373
377
  - `message.im` (direct messages)
@@ -399,7 +403,12 @@ end
399
403
  bundle exec rake rails_console_ai:slack
400
404
  ```
401
405
 
402
- This starts a long-running process (run it separately from your web server). Each new message creates a session; threaded replies continue the conversation. The bot auto-executes code with safety guards always enabled — there is no `/danger` equivalent in Slack.
406
+ This starts a long-running process (run it separately from your web server). The bot auto-executes code with safety guards always enabled — there is no `/danger` equivalent in Slack.
407
+
408
+ **@mention behavior:**
409
+ - **DMs** — the bot responds to all messages, no @mention needed.
410
+ - **Channels** — the bot only responds when @mentioned. @mention it in any channel message or thread to start a session. The person who first @mentions the bot owns the session — only they can continue the conversation, and they must @mention the bot on each message. Exception: when the bot asks a question, the owner can reply without @mentioning.
411
+ - **Joining threads** — when @mentioned mid-thread, the bot reads the thread history for context so it understands what's already been discussed.
403
412
 
404
413
  ## Requirements
405
414
 
@@ -13,6 +13,7 @@
13
13
  <tr>
14
14
  <th>Time</th>
15
15
  <th>User</th>
16
+ <th>Channel</th>
16
17
  <th>Name</th>
17
18
  <th style="max-width: 400px;">Query</th>
18
19
  <th>Mode</th>
@@ -26,6 +27,7 @@
26
27
  <tr>
27
28
  <td class="mono"><%= session.created_at.strftime('%Y-%m-%d %H:%M') %></td>
28
29
  <td><%= session.user_name %></td>
30
+ <td><%= session.try(:slack_channel_name).presence || '-' %></td>
29
31
  <td><%= session.name.present? ? session.name : '-' %></td>
30
32
  <td class="query-cell"><a href="<%= rails_console_ai.session_path(session) %>" title="<%= h session.query.truncate(200) %>"><%= truncate(session.query.gsub(/\s+/, ' ').strip, length: 80) %></a></td>
31
33
  <td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
@@ -30,6 +30,12 @@
30
30
  <label>User</label>
31
31
  <span><%= @session.user_name || '-' %></span>
32
32
  </div>
33
+ <% if @session.try(:slack_channel_name).present? %>
34
+ <div class="meta-item">
35
+ <label>Channel</label>
36
+ <span><%= @session.slack_channel_name %></span>
37
+ </div>
38
+ <% end %>
33
39
  <div class="meta-item">
34
40
  <label>Provider / Model</label>
35
41
  <span><%= @session.provider %> / <%= @session.model %></span>
@@ -37,6 +37,33 @@ module RailsConsoleAi
37
37
  $stdout.puts
38
38
  end
39
39
 
40
+ def display_result_output(output)
41
+ text = output.to_s
42
+ return if text.strip.empty?
43
+
44
+ lines = text.lines
45
+ total_lines = lines.length
46
+ total_chars = text.length
47
+
48
+ if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
49
+ $stdout.print text
50
+ else
51
+ truncated = lines.first(MAX_DISPLAY_LINES).join
52
+ truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
53
+ $stdout.print truncated
54
+
55
+ omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
56
+ omitted_chars = [total_chars - truncated.length, 0].max
57
+ parts = []
58
+ parts << "#{omitted_lines} lines" if omitted_lines > 0
59
+ parts << "#{omitted_chars} chars" if omitted_chars > 0
60
+
61
+ @omitted_counter += 1
62
+ @omitted_outputs[@omitted_counter] = text
63
+ $stdout.puts colorize(" (output truncated, omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
64
+ end
65
+ end
66
+
40
67
  def display_result(result)
41
68
  full = "=> #{result.inspect}"
42
69
  lines = full.lines
@@ -255,8 +282,12 @@ module RailsConsoleAi
255
282
  @engine.display_conversation
256
283
  when '/cost'
257
284
  @engine.display_cost_summary
285
+ when '/model'
286
+ display_model_info
258
287
  when '/think'
259
288
  @engine.upgrade_to_thinking_model
289
+ when '/unthink'
290
+ @engine.downgrade_from_thinking_model
260
291
  when /\A\/expand/
261
292
  expand_id = input.sub('/expand', '').strip.to_i
262
293
  full_output = expand_output(expand_id)
@@ -320,6 +351,27 @@ module RailsConsoleAi
320
351
  end
321
352
  end
322
353
 
354
+ def display_model_info
355
+ config = RailsConsoleAi.configuration
356
+ model = @engine.effective_model
357
+ thinking = config.resolved_thinking_model
358
+ pricing = Configuration::PRICING[model]
359
+
360
+ @real_stdout.puts "\e[36m Model info:\e[0m"
361
+ @real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"
362
+ @real_stdout.puts "\e[2m Model: #{model}\e[0m"
363
+ @real_stdout.puts "\e[2m Thinking model: #{thinking}\e[0m"
364
+ @real_stdout.puts "\e[2m Max tokens: #{config.resolved_max_tokens}\e[0m"
365
+ if pricing
366
+ @real_stdout.puts "\e[2m Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out\e[0m"
367
+ if pricing[:cache_read]
368
+ @real_stdout.puts "\e[2m Cache pricing: $#{pricing[:cache_read] * 1_000_000}/M read, $#{pricing[:cache_write] * 1_000_000}/M write\e[0m"
369
+ end
370
+ end
371
+ @real_stdout.puts "\e[2m Bedrock region: #{config.bedrock_region}\e[0m" if config.provider == :bedrock
372
+ @real_stdout.puts "\e[2m Local URL: #{config.local_url}\e[0m" if config.provider == :local
373
+ end
374
+
323
375
  def handle_name_command(input)
324
376
  name = input.sub('/name', '').strip.gsub(/\A(['"])(.*)\1\z/, '\2')
325
377
  if name.empty?
@@ -344,7 +396,9 @@ module RailsConsoleAi
344
396
  @real_stdout.puts "\e[2m /danger Toggle safe mode (currently #{safe_status})\e[0m"
345
397
  @real_stdout.puts "\e[2m /safe Show safety guard status\e[0m"
346
398
  end
399
+ @real_stdout.puts "\e[2m /model Show provider, model, and pricing info\e[0m"
347
400
  @real_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
401
+ @real_stdout.puts "\e[2m /unthink Switch back to default model\e[0m"
348
402
  @real_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
349
403
  @real_stdout.puts "\e[2m /usage Show session token totals\e[0m"
350
404
  @real_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
@@ -130,10 +130,14 @@ module RailsConsoleAi
130
130
  nil
131
131
  end
132
132
 
133
- def ai_setup
133
+ def ai_db_setup
134
134
  RailsConsoleAi.setup!
135
135
  end
136
136
 
137
+ def ai_db_migrate
138
+ RailsConsoleAi.migrate!
139
+ end
140
+
137
141
  def ai_init
138
142
  require 'rails_console_ai/context_builder'
139
143
  require 'rails_console_ai/providers/base'
@@ -157,7 +161,8 @@ module RailsConsoleAi
157
161
  $stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
158
162
  $stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
159
163
  $stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
160
- $stderr.puts "\e[33m ai_setup - install session logging table\e[0m"
164
+ $stderr.puts "\e[33m ai_db_setup - install session logging table + run migrations\e[0m"
165
+ $stderr.puts "\e[33m ai_db_migrate- run pending session table migrations\e[0m"
161
166
  $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
162
167
  $stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
163
168
  return nil
@@ -4,11 +4,14 @@ module RailsConsoleAi
4
4
  :interactive_session_id, :session_name
5
5
 
6
6
  RECENT_OUTPUTS_TO_KEEP = 2
7
+ LARGE_OUTPUT_THRESHOLD = 10_000 # chars — truncate tool results larger than this immediately
8
+ LARGE_OUTPUT_PREVIEW_CHARS = 8_000 # chars — how much of the output the LLM sees upfront
7
9
 
8
- def initialize(binding_context:, channel:, slack_thread_ts: nil)
10
+ def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
9
11
  @binding_context = binding_context
10
12
  @channel = channel
11
13
  @slack_thread_ts = slack_thread_ts
14
+ @slack_channel_name = slack_channel_name
12
15
  @executor = Executor.new(binding_context, channel: channel)
13
16
  @provider = nil
14
17
  @context_builder = nil
@@ -295,7 +298,6 @@ module RailsConsoleAi
295
298
  end
296
299
 
297
300
  if @executor.last_cancelled?
298
- @history << { role: :user, content: "User declined to execute the code." }
299
301
  :cancelled
300
302
  elsif @executor.last_safety_error
301
303
  exec_result = @executor.offer_danger_retry(code)
@@ -318,7 +320,6 @@ module RailsConsoleAi
318
320
  end
319
321
  :success
320
322
  else
321
- @history << { role: :user, content: "User declined to execute with safe mode disabled." }
322
323
  :cancelled
323
324
  end
324
325
  elsif @executor.last_error
@@ -417,16 +418,36 @@ module RailsConsoleAi
417
418
 
418
419
  def upgrade_to_thinking_model
419
420
  config = RailsConsoleAi.configuration
420
- current = config.resolved_model
421
+ current = effective_model
421
422
  thinking = config.resolved_thinking_model
422
423
 
423
424
  if current == thinking
424
425
  $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
425
426
  else
426
- config.model = thinking
427
+ @model_override = thinking
427
428
  @provider = nil
428
429
  $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
429
430
  end
431
+ effective_model
432
+ end
433
+
434
+ def downgrade_from_thinking_model
435
+ config = RailsConsoleAi.configuration
436
+ default = config.resolved_model
437
+ current = effective_model
438
+
439
+ if current == default && @model_override.nil?
440
+ $stdout.puts "\e[36m Already using default model (#{current}).\e[0m"
441
+ else
442
+ @model_override = nil
443
+ @provider = nil
444
+ $stdout.puts "\e[36m Switched back to default model: #{default}\e[0m"
445
+ end
446
+ effective_model
447
+ end
448
+
449
+ def effective_model
450
+ @model_override || RailsConsoleAi.configuration.resolved_model
430
451
  end
431
452
 
432
453
  def compact_history
@@ -522,6 +543,8 @@ module RailsConsoleAi
522
543
  name: @session_name
523
544
  )
524
545
  log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
546
+ log_attrs[:slack_channel_name] = @slack_channel_name if @slack_channel_name
547
+ log_attrs[:model] = effective_model
525
548
  if @channel.user_identity
526
549
  log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
527
550
  end
@@ -558,6 +581,7 @@ module RailsConsoleAi
558
581
  start_time: @interactive_start
559
582
  }
560
583
  log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
584
+ log_attrs[:slack_channel_name] = @slack_channel_name if @slack_channel_name
561
585
  if @channel.user_identity
562
586
  log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
563
587
  end
@@ -619,7 +643,15 @@ module RailsConsoleAi
619
643
  end
620
644
 
621
645
  def provider
622
- @provider ||= Providers.build
646
+ @provider ||= begin
647
+ if @model_override
648
+ config = RailsConsoleAi.configuration.dup
649
+ config.model = @model_override
650
+ Providers.build(config)
651
+ else
652
+ Providers.build
653
+ end
654
+ end
623
655
  end
624
656
 
625
657
  def context_builder
@@ -736,6 +768,10 @@ module RailsConsoleAi
736
768
  @channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
737
769
  end
738
770
 
771
+ # Trim old tool outputs between rounds to prevent context explosion.
772
+ # The LLM can still retrieve omitted outputs via recall_output.
773
+ messages = trim_old_outputs(messages) if round > 0
774
+
739
775
  if RailsConsoleAi.configuration.debug
740
776
  debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
741
777
  end
@@ -790,13 +826,25 @@ module RailsConsoleAi
790
826
  end
791
827
 
792
828
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
793
- if tool_result.to_s.length > 200
794
- tool_msg[:output_id] = @executor.store_output(tool_result.to_s)
829
+ full_text = tool_result.to_s
830
+ if full_text.length > LARGE_OUTPUT_THRESHOLD
831
+ output_id = @executor.store_output(full_text)
832
+ tool_msg[:output_id] = output_id
833
+ truncated = full_text[0, LARGE_OUTPUT_PREVIEW_CHARS]
834
+ truncated += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{full_text.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
835
+ tool_msg = provider.format_tool_result(tc[:id], truncated)
836
+ tool_msg[:output_id] = output_id
837
+ elsif full_text.length > 200
838
+ tool_msg[:output_id] = @executor.store_output(full_text)
795
839
  end
796
840
  messages << tool_msg
797
841
  new_messages << tool_msg
798
842
  end
799
843
 
844
+ # If the user declined execution, don't call the LLM again —
845
+ # just return to the prompt so they can correct their request.
846
+ break if @executor.last_cancelled?
847
+
800
848
  exhausted = true if round == max_rounds - 1
801
849
  end
802
850
 
@@ -821,7 +869,7 @@ module RailsConsoleAi
821
869
  @total_input_tokens += result.input_tokens || 0
822
870
  @total_output_tokens += result.output_tokens || 0
823
871
 
824
- model = RailsConsoleAi.configuration.resolved_model
872
+ model = effective_model
825
873
  @token_usage[model][:input] += result.input_tokens || 0
826
874
  @token_usage[model][:output] += result.output_tokens || 0
827
875
  @token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
@@ -865,7 +913,8 @@ module RailsConsoleAi
865
913
  attrs.merge(
866
914
  input_tokens: @total_input_tokens,
867
915
  output_tokens: @total_output_tokens,
868
- duration_ms: duration_ms
916
+ duration_ms: duration_ms,
917
+ model: effective_model
869
918
  )
870
919
  )
871
920
  end
@@ -896,6 +945,8 @@ module RailsConsoleAi
896
945
  when 'save_memory' then "(\"#{args['name']}\")"
897
946
  when 'delete_memory' then "(\"#{args['name']}\")"
898
947
  when 'recall_memories' then args['query'] ? "(\"#{args['query']}\")" : ''
948
+ when 'activate_skill' then "(\"#{args['name']}\")"
949
+ when 'recall_output' then "(#{args['id']})"
899
950
  when 'execute_plan'
900
951
  steps = args['steps']
901
952
  steps ? "(#{steps.length} steps)" : ''
@@ -1021,7 +1072,7 @@ module RailsConsoleAi
1021
1072
 
1022
1073
  input_t = result.input_tokens || 0
1023
1074
  output_t = result.output_tokens || 0
1024
- model = RailsConsoleAi.configuration.resolved_model
1075
+ model = effective_model
1025
1076
  pricing = Configuration::PRICING[model]
1026
1077
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1027
1078
 
@@ -100,8 +100,13 @@ module RailsConsoleAi
100
100
  @last_safety_exception = nil
101
101
  captured_output = StringIO.new
102
102
  old_stdout = $stdout
103
- # Tee output: capture it and also print to the real stdout
104
- $stdout = TeeIO.new(old_stdout, captured_output)
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
105
110
 
106
111
  RailsConsoleAi::SafetyError.clear!
107
112
 
@@ -229,31 +234,6 @@ module RailsConsoleAi
229
234
  $stdout.puts colorize("Executing with safety guards disabled.", :red)
230
235
  end
231
236
  return execute_unsafe(code)
232
- when 'e', 'edit'
233
- edited = if @channel && @channel.supports_editing?
234
- @channel.edit_code(code)
235
- else
236
- open_in_editor(code)
237
- end
238
- if edited && edited != code
239
- $stdout.puts colorize("# Edited code:", :yellow)
240
- $stdout.puts highlight_code(edited)
241
- if @channel
242
- edit_answer = @channel.confirm("Execute edited code? [y/N] ")
243
- else
244
- $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
245
- edit_answer = $stdin.gets.to_s.strip.downcase
246
- echo_stdin(edit_answer)
247
- end
248
- if edit_answer == 'y'
249
- return execute(edited)
250
- else
251
- $stdout.puts colorize("Cancelled.", :yellow)
252
- return nil
253
- end
254
- else
255
- return execute(code)
256
- end
257
237
  when 'n', 'no', ''
258
238
  $stdout.puts colorize("Cancelled.", :yellow)
259
239
  @last_cancelled = true
@@ -366,9 +346,9 @@ module RailsConsoleAi
366
346
  def execute_prompt
367
347
  guards = RailsConsoleAi.configuration.safety_guards
368
348
  if !guards.empty? && guards.enabled? && danger_allowed?
369
- "Execute? [y/N/edit/danger] "
349
+ "Execute? [y/N/danger] "
370
350
  else
371
- "Execute? [y/N/edit] "
351
+ "Execute? [y/N] "
372
352
  end
373
353
  end
374
354
 
@@ -72,8 +72,8 @@ module RailsConsoleAi
72
72
  text: extract_text(response),
73
73
  input_tokens: usage&.input_tokens,
74
74
  output_tokens: usage&.output_tokens,
75
- cache_read_input_tokens: usage.respond_to?(:cache_read_input_token_count) ? usage.cache_read_input_token_count : nil,
76
- cache_write_input_tokens: usage.respond_to?(:cache_write_input_token_count) ? usage.cache_write_input_token_count : nil,
75
+ cache_read_input_tokens: usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens : nil,
76
+ cache_write_input_tokens: usage.respond_to?(:cache_write_input_tokens) ? usage.cache_write_input_tokens : nil,
77
77
  tool_calls: tool_calls,
78
78
  stop_reason: stop
79
79
  )
@@ -175,7 +175,9 @@ module RailsConsoleAi
175
175
 
176
176
  usage = response.usage
177
177
  if usage
178
- $stderr.puts "\e[36m[debug] response: #{response.stop_reason} | in: #{usage.input_tokens} out: #{usage.output_tokens}\e[0m"
178
+ cache_r = usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens : 'N/A'
179
+ cache_w = usage.respond_to?(:cache_write_input_tokens) ? usage.cache_write_input_tokens : 'N/A'
180
+ $stderr.puts "\e[36m[debug] response: #{response.stop_reason} | in: #{usage.input_tokens} out: #{usage.output_tokens} | cache_r: #{cache_r} cache_w: #{cache_w}\e[0m"
179
181
  end
180
182
  end
181
183
  end
@@ -113,7 +113,16 @@ module RailsConsoleAi
113
113
  @installed_bypass_specs ||= Set.new
114
114
  return if @installed_bypass_specs.include?(spec)
115
115
 
116
- class_name, method_name = spec.split('#')
116
+ if spec.include?('.')
117
+ class_name, method_name = spec.split('.')
118
+ class_method = true
119
+ else
120
+ class_name, method_name = spec.split('#')
121
+ class_method = false
122
+ end
123
+
124
+ return unless method_name && !method_name.empty?
125
+
117
126
  klass = Object.const_get(class_name) rescue return
118
127
  method_sym = method_name.to_sym
119
128
 
@@ -126,7 +135,12 @@ module RailsConsoleAi
126
135
  end
127
136
  end
128
137
  end
129
- klass.prepend(bypass_mod)
138
+
139
+ if class_method
140
+ klass.singleton_class.prepend(bypass_mod)
141
+ else
142
+ klass.prepend(bypass_mod)
143
+ end
130
144
  @installed_bypass_specs << spec
131
145
  end
132
146
 
@@ -19,11 +19,12 @@ module RailsConsoleAi
19
19
  console_output: attrs[:console_output],
20
20
  executed: attrs[:executed] || false,
21
21
  provider: RailsConsoleAi.configuration.provider.to_s,
22
- model: RailsConsoleAi.configuration.resolved_model,
22
+ model: attrs[:model] || RailsConsoleAi.configuration.resolved_model,
23
23
  duration_ms: attrs[:duration_ms],
24
24
  created_at: Time.respond_to?(:current) ? Time.current : Time.now
25
25
  }
26
26
  create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
+ create_attrs[:slack_channel_name] = attrs[:slack_channel_name] if attrs[:slack_channel_name]
27
28
  record = session_class.create!(create_attrs)
28
29
  record.id
29
30
  rescue => e
@@ -25,8 +25,10 @@ module RailsConsoleAi
25
25
  raise ConfigurationError, "slack_allowed_usernames must be configured (e.g. ['alice'] or 'ALL')" unless RailsConsoleAi.configuration.slack_allowed_usernames
26
26
 
27
27
  @bot_user_id = nil
28
- @sessions = {} # thread_ts → { channel:, engine:, thread: }
28
+ @sessions = {} # thread_ts → { channel:, engine:, thread:, owner_user_id: }
29
29
  @user_cache = {} # slack user_id → display_name
30
+ @channel_cache = {} # channel_id → channel name
31
+ @processed_ts = {} # ts → Time — dedup app_mention vs message events
30
32
  @mutex = Mutex.new
31
33
  end
32
34
 
@@ -282,33 +284,70 @@ module RailsConsoleAi
282
284
  def handle_event(msg)
283
285
  event = msg.dig(:payload, :event)
284
286
  return unless event
285
- return unless event[:type] == "message"
287
+ return unless event[:type] == "message" || event[:type] == "app_mention"
286
288
 
287
289
  # Ignore bot messages, subtypes (edits/deletes), own messages
288
290
  return if event[:bot_id]
289
291
  return if event[:user] == @bot_user_id
290
292
  return if event[:subtype]
291
293
 
292
- text = unescape_slack(event[:text])
294
+ raw_text = event[:text]
295
+ text = unescape_slack(raw_text)
293
296
  return unless text && !text.strip.empty?
294
297
 
295
298
  channel_id = event[:channel]
296
- return unless watched_channel?(channel_id)
297
-
298
299
  thread_ts = event[:thread_ts] || event[:ts]
299
300
  user_id = event[:user]
301
+ is_dm = event[:channel_type] == "im"
302
+ mentioned = event[:type] == "app_mention" || mentions_bot?(raw_text)
303
+
304
+ # --- Channel gating (DMs skip this entirely) ---
305
+ unless is_dm
306
+ # Dedup: Slack fires both message and app_mention for @mentions
307
+ return if mentioned && dedup_event?(event[:ts])
308
+
309
+ # Strip bot mention from text
310
+ text = strip_bot_mention(text).strip
311
+ return if text.empty?
312
+
313
+ session = @mutex.synchronize { @sessions[thread_ts] }
314
+ if session
315
+ # Enforce session ownership
316
+ unless session[:owner_user_id] == user_id
317
+ # Non-owner: tell unrecognized users, silently ignore recognized non-owners
318
+ chk_name = resolve_user_name(user_id)
319
+ chk_list = Array(RailsConsoleAi.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
320
+ unless chk_list.include?('all') || chk_list.include?(chk_name.to_s.downcase)
321
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{chk_name} << (ignored — not in allowed usernames)"
322
+ 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.")
323
+ end
324
+ return
325
+ end
326
+ # Owner must @mention unless bot asked a question (waiting_for_reply?)
327
+ return unless mentioned || waiting_for_reply?(session[:channel])
328
+ # Log thread messages since last mention
329
+ thread_msgs = fetch_thread_messages(channel_id, thread_ts, since_ts: session[:last_seen_ts], exclude_ts: event[:ts])
330
+ log_thread_messages(thread_msgs, channel_id, thread_ts)
331
+ session[:last_seen_ts] = event[:ts]
332
+ else
333
+ # No existing session: only respond if @mentioned
334
+ return unless mentioned
335
+ end
336
+ end
337
+
338
+ # --- Common processing (DMs and channels) ---
300
339
  user_name = resolve_user_name(user_id)
301
340
 
302
341
  allowed_list = Array(RailsConsoleAi.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
303
342
  unless allowed_list.include?('all') || allowed_list.include?(user_name.to_s.downcase)
304
- puts "[#{channel_id}/#{thread_ts}] @#{user_name} << (ignored — not in allowed usernames)"
343
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name} << (ignored — not in allowed usernames)"
305
344
  post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{user_name}). Ask an admin to add you to the allowed usernames list.")
306
345
  return
307
346
  end
308
347
 
309
- puts "[#{channel_id}/#{thread_ts}] @#{user_name} << #{text.strip}"
348
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name} << #{text.strip}"
310
349
 
311
- session = @mutex.synchronize { @sessions[thread_ts] }
350
+ session ||= @mutex.synchronize { @sessions[thread_ts] }
312
351
 
313
352
  command = text.strip.downcase
314
353
  if command == 'cancel' || command == 'stop'
@@ -333,6 +372,12 @@ module RailsConsoleAi
333
372
  return
334
373
  end
335
374
 
375
+ # Bang commands (!name, !usage, etc.) — handle before LLM
376
+ if text.strip.start_with?('!')
377
+ handle_bang_command(session, channel_id, thread_ts, text.strip, user_name, user_id)
378
+ return
379
+ end
380
+
336
381
  # Quick greeting — no need to hit the LLM
337
382
  if !session && GREETINGS.include?(command)
338
383
  post_message(channel: channel_id, thread_ts: thread_ts, text: ":wave: Hey @#{user_name}! What would you like to look into today?")
@@ -342,21 +387,32 @@ module RailsConsoleAi
342
387
  # Direct code execution: code blocks or "> User.count" run code without LLM
343
388
  raw_code = extract_direct_code(text.strip)
344
389
  if raw_code
345
- handle_direct_code(session, channel_id, thread_ts, raw_code, user_name)
390
+ handle_direct_code(session, channel_id, thread_ts, raw_code, user_name, user_id)
346
391
  return
347
392
  end
348
393
 
349
394
  if session
350
395
  handle_thread_reply(session, text.strip)
351
396
  else
352
- # New thread, or existing thread after bot restart — start a fresh session
353
- start_session(channel_id, thread_ts, text.strip, user_name)
397
+ # Fetch thread context if joining a mid-conversation thread
398
+ thread_context = nil
399
+ if !is_dm && event[:thread_ts]
400
+ thread_msgs = fetch_thread_messages(channel_id, thread_ts, exclude_ts: event[:ts])
401
+ log_thread_messages(thread_msgs, channel_id, thread_ts)
402
+ thread_context = build_thread_context(thread_msgs)
403
+ end
404
+ start_session(channel_id, thread_ts, text.strip, user_name, user_id: user_id, thread_context: thread_context)
405
+ # Track last seen message for subsequent mentions
406
+ if !is_dm
407
+ new_session = @mutex.synchronize { @sessions[thread_ts] }
408
+ new_session[:last_seen_ts] = event[:ts] if new_session
409
+ end
354
410
  end
355
411
  rescue => e
356
412
  RailsConsoleAi.logger.error("SlackBot event handling error: #{e.class}: #{e.message}")
357
413
  end
358
414
 
359
- def start_session(channel_id, thread_ts, text, user_name)
415
+ def start_session(channel_id, thread_ts, text, user_name, user_id: nil, thread_context: nil)
360
416
  channel = Channel::Slack.new(
361
417
  slack_bot: self,
362
418
  channel_id: channel_id,
@@ -368,18 +424,24 @@ module RailsConsoleAi
368
424
  engine = ConversationEngine.new(
369
425
  binding_context: sandbox_binding,
370
426
  channel: channel,
371
- slack_thread_ts: thread_ts
427
+ slack_thread_ts: thread_ts,
428
+ slack_channel_name: resolve_channel_name(channel_id)
372
429
  )
373
430
 
374
431
  # Try to restore conversation history from a previous session (e.g. after bot restart)
375
432
  restored = restore_from_db(engine, thread_ts)
376
433
 
377
- session = { channel: channel, engine: engine, thread: nil }
434
+ # Prepend thread context only when joining a thread fresh (no DB session to restore)
435
+ if !restored && thread_context
436
+ text = "#{thread_context}\n\n#{text}"
437
+ end
438
+
439
+ session = { channel: channel, engine: engine, thread: nil, owner_user_id: user_id }
378
440
  @mutex.synchronize { @sessions[thread_ts] = session }
379
441
 
380
442
  session[:thread] = Thread.new do
381
443
  Thread.current.report_on_exception = false
382
- Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}] @#{user_name}"
444
+ Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
383
445
  begin
384
446
  channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
385
447
  if restored
@@ -434,10 +496,10 @@ module RailsConsoleAi
434
496
  end
435
497
  end
436
498
 
437
- def handle_direct_code(session, channel_id, thread_ts, raw_code, user_name)
499
+ def handle_direct_code(session, channel_id, thread_ts, raw_code, user_name, user_id)
438
500
  # Ensure a session exists for this thread
439
501
  unless session
440
- start_direct_session(channel_id, thread_ts, user_name)
502
+ start_direct_session(channel_id, thread_ts, user_name, user_id)
441
503
  session = @mutex.synchronize { @sessions[thread_ts] }
442
504
  end
443
505
 
@@ -467,7 +529,7 @@ module RailsConsoleAi
467
529
  end
468
530
  end
469
531
 
470
- def start_direct_session(channel_id, thread_ts, user_name)
532
+ def start_direct_session(channel_id, thread_ts, user_name, user_id)
471
533
  channel = Channel::Slack.new(
472
534
  slack_bot: self,
473
535
  channel_id: channel_id,
@@ -479,13 +541,14 @@ module RailsConsoleAi
479
541
  engine = ConversationEngine.new(
480
542
  binding_context: sandbox_binding,
481
543
  channel: channel,
482
- slack_thread_ts: thread_ts
544
+ slack_thread_ts: thread_ts,
545
+ slack_channel_name: resolve_channel_name(channel_id)
483
546
  )
484
547
 
485
548
  restore_from_db(engine, thread_ts)
486
549
  engine.init_interactive unless engine.instance_variable_get(:@interactive_start)
487
550
 
488
- session = { channel: channel, engine: engine, thread: nil }
551
+ session = { channel: channel, engine: engine, thread: nil, owner_user_id: user_id }
489
552
  @mutex.synchronize { @sessions[thread_ts] = session }
490
553
  end
491
554
 
@@ -493,7 +556,7 @@ module RailsConsoleAi
493
556
  if session
494
557
  session[:channel].cancel!
495
558
  session[:channel].display("Stopped.")
496
- puts "[#{channel_id}/#{thread_ts}] cancel requested"
559
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} cancel requested"
497
560
 
498
561
  # Record stop in conversation history so restored sessions know
499
562
  # the previous topic was abandoned by the user
@@ -507,11 +570,189 @@ module RailsConsoleAi
507
570
  end
508
571
  else
509
572
  post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
510
- puts "[#{channel_id}/#{thread_ts}] cancel: no session"
573
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} cancel: no session"
511
574
  end
512
575
  @mutex.synchronize { @sessions.delete(thread_ts) }
513
576
  end
514
577
 
578
+ def handle_bang_command(session, channel_id, thread_ts, text, user_name, user_id)
579
+ parts = text.sub(/\A!/, '').split(/\s+/, 2)
580
+ cmd = parts[0].to_s.downcase
581
+ arg = parts[1].to_s.strip
582
+
583
+ # !name requires an active session (or creates one)
584
+ # Other commands require an existing session
585
+ unless session
586
+ if cmd == 'name' && !arg.empty?
587
+ # Start a session so we can name it
588
+ start_direct_session(channel_id, thread_ts, user_name, user_id)
589
+ session = @mutex.synchronize { @sessions[thread_ts] }
590
+ else
591
+ post_message(channel: channel_id, thread_ts: thread_ts,
592
+ text: "No active session in this thread. Start a conversation first.")
593
+ return
594
+ end
595
+ end
596
+
597
+ engine = session[:engine]
598
+
599
+ case cmd
600
+ when 'name'
601
+ handle_bang_name(engine, channel_id, thread_ts, arg)
602
+ when 'usage'
603
+ summary = bang_usage(engine)
604
+ post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
605
+ when 'cost'
606
+ summary = bang_cost(engine)
607
+ post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
608
+ when 'compact'
609
+ session[:thread] = Thread.new do
610
+ Thread.current.report_on_exception = false
611
+ begin
612
+ before = engine.history.length
613
+ engine.compact_history
614
+ after = engine.history.length
615
+ if after < before
616
+ post_message(channel: channel_id, thread_ts: thread_ts,
617
+ text: "Compacted: #{before} messages → #{after} summary message")
618
+ end
619
+ rescue => e
620
+ post_message(channel: channel_id, thread_ts: thread_ts,
621
+ text: "Compact failed: #{e.message}")
622
+ ensure
623
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
624
+ end
625
+ end
626
+ when 'model'
627
+ summary = bang_model(engine)
628
+ post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
629
+ when 'think'
630
+ model = engine.upgrade_to_thinking_model
631
+ post_message(channel: channel_id, thread_ts: thread_ts,
632
+ text: "Switched to thinking model: `#{model}`")
633
+ when 'unthink'
634
+ model = engine.downgrade_from_thinking_model
635
+ post_message(channel: channel_id, thread_ts: thread_ts,
636
+ text: "Switched to default model: `#{model}`")
637
+ when 'context'
638
+ summary = bang_context(engine)
639
+ post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
640
+ when 'retry'
641
+ session[:thread] = Thread.new do
642
+ Thread.current.report_on_exception = false
643
+ Thread.current[:log_prefix] = session[:channel].instance_variable_get(:@log_prefix)
644
+ begin
645
+ engine.retry_last_code
646
+ engine.send(:log_interactive_turn)
647
+ rescue => e
648
+ post_message(channel: channel_id, thread_ts: thread_ts,
649
+ text: "Retry failed: #{e.message}")
650
+ ensure
651
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
652
+ end
653
+ end
654
+ else
655
+ commands = %w[name usage cost model compact think unthink context retry].map { |c| "`!#{c}`" }
656
+ post_message(channel: channel_id, thread_ts: thread_ts,
657
+ text: "Unknown command `!#{cmd}`. Available: #{commands.join(', ')}")
658
+ end
659
+ end
660
+
661
+ def handle_bang_name(engine, channel_id, thread_ts, arg)
662
+ name = arg.gsub(/\A['"]|['"]\z/, '')
663
+ if name.empty?
664
+ current = engine.session_name
665
+ if current
666
+ post_message(channel: channel_id, thread_ts: thread_ts,
667
+ text: "Session name: *#{current}*\nResume in console with: `ai_resume \"#{current}\"`")
668
+ else
669
+ post_message(channel: channel_id, thread_ts: thread_ts,
670
+ text: "Usage: `!name <label>` — e.g. `!name salesforce_debug`")
671
+ end
672
+ else
673
+ engine.set_session_name(name)
674
+ post_message(channel: channel_id, thread_ts: thread_ts,
675
+ text: "Session named: *#{name}*\nResume in console with: `ai_resume \"#{name}\"`")
676
+ end
677
+ end
678
+
679
+ def bang_model(engine = nil)
680
+ config = RailsConsoleAi.configuration
681
+ model = engine ? engine.effective_model : config.resolved_model
682
+ thinking = config.resolved_thinking_model
683
+ pricing = Configuration::PRICING[model]
684
+
685
+ lines = ["*Model info:*"]
686
+ lines << " Provider: `#{config.provider}`"
687
+ lines << " Model: `#{model}`"
688
+ lines << " Thinking model: `#{thinking}`"
689
+ lines << " Max tokens: #{config.resolved_max_tokens}"
690
+ if pricing
691
+ lines << " Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out"
692
+ if pricing[:cache_read]
693
+ lines << " Cache: $#{pricing[:cache_read] * 1_000_000}/M read, $#{pricing[:cache_write] * 1_000_000}/M write"
694
+ end
695
+ end
696
+ lines << " Region: `#{config.bedrock_region}`" if config.provider == :bedrock
697
+ lines.join("\n")
698
+ end
699
+
700
+ def bang_usage(engine)
701
+ input = engine.total_input_tokens
702
+ output = engine.total_output_tokens
703
+ total = input + output
704
+ return "No usage yet." if total == 0
705
+ "Session totals — in: #{input} | out: #{output} | total: #{total}"
706
+ end
707
+
708
+ def bang_cost(engine)
709
+ token_usage = engine.instance_variable_get(:@token_usage)
710
+ return "No usage yet." if token_usage.empty?
711
+
712
+ lines = ["*Cost estimate:*"]
713
+ total_cost = 0.0
714
+
715
+ token_usage.each do |model, usage|
716
+ pricing = Configuration::PRICING[model]
717
+ pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
718
+ input_str = "in: #{usage[:input]}"
719
+ output_str = "out: #{usage[:output]}"
720
+
721
+ if pricing
722
+ cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
723
+ cache_read = usage[:cache_read] || 0
724
+ cache_write = usage[:cache_write] || 0
725
+ if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
726
+ cost -= cache_read * pricing[:input]
727
+ cost += cache_read * pricing[:cache_read]
728
+ cost += cache_write * (pricing[:cache_write] - pricing[:input])
729
+ end
730
+ total_cost += cost
731
+ cache_str = ""
732
+ cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
733
+ lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}"
734
+ else
735
+ lines << " `#{model}`: #{input_str} #{output_str} (pricing unknown)"
736
+ end
737
+ end
738
+
739
+ lines << "*Total: ~$#{'%.2f' % total_cost}*"
740
+ lines.join("\n")
741
+ end
742
+
743
+ def bang_context(engine)
744
+ history = engine.history
745
+ return "No conversation history yet." if history.empty?
746
+
747
+ msg_count = history.length
748
+ chars = history.sum { |m| m[:content].to_s.length }
749
+ user_msgs = history.count { |m| m[:role].to_s == 'user' }
750
+ asst_msgs = history.count { |m| m[:role].to_s == 'assistant' }
751
+ name_str = engine.session_name ? " (*#{engine.session_name}*)" : ""
752
+
753
+ "Context#{name_str}: #{msg_count} messages (#{user_msgs} user, #{asst_msgs} assistant), ~#{chars} chars"
754
+ end
755
+
515
756
  def count_bot_messages(channel_id, thread_ts)
516
757
  result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
517
758
  return 0 unless result["ok"]
@@ -523,18 +764,18 @@ module RailsConsoleAi
523
764
  def clear_bot_messages(channel_id, thread_ts)
524
765
  result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
525
766
  unless result["ok"]
526
- puts "[#{channel_id}/#{thread_ts}] clear: failed to fetch replies: #{result["error"]}"
767
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} clear: failed to fetch replies: #{result["error"]}"
527
768
  return
528
769
  end
529
770
 
530
771
  bot_messages = (result["messages"] || []).select { |m| m["user"] == @bot_user_id }
531
772
  bot_messages.each do |m|
532
- puts "[#{channel_id}/#{thread_ts}] clearing #{channel_id.length} / #{m["ts"]}"
773
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} clearing #{channel_id.length} / #{m["ts"]}"
533
774
  slack_api("chat.delete", channel: channel_id, ts: m["ts"])
534
775
  end
535
- puts "[#{channel_id}/#{thread_ts}] cleared #{bot_messages.length} bot messages"
776
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} cleared #{bot_messages.length} bot messages"
536
777
  rescue => e
537
- puts "[#{channel_id}/#{thread_ts}] clear failed: #{e.message}"
778
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} clear failed: #{e.message}"
538
779
  end
539
780
 
540
781
  def slack_get(method, **params)
@@ -619,6 +860,102 @@ module RailsConsoleAi
619
860
  @user_cache[user_id] = user_id
620
861
  end
621
862
 
863
+ def resolve_channel_name(channel_id)
864
+ return @channel_cache[channel_id] if @channel_cache.key?(channel_id)
865
+
866
+ result = slack_get("conversations.info", channel: channel_id)
867
+ if result["ok"]
868
+ name = result.dig("channel", "name")
869
+ @channel_cache[channel_id] = name
870
+ else
871
+ puts "WARNING: conversations.info failed for #{channel_id}: #{result["error"]} — add channels:read scope to your Slack app for channel names in logs"
872
+ @channel_cache[channel_id] = nil
873
+ end
874
+ rescue => e
875
+ RailsConsoleAi.logger.warn("Failed to resolve channel name for #{channel_id}: #{e.message}")
876
+ @channel_cache[channel_id] = nil
877
+ end
878
+
879
+ def channel_log_tag(channel_id)
880
+ name = resolve_channel_name(channel_id)
881
+ name ? " (#{name})" : ""
882
+ end
883
+
884
+ # --- @mention helpers ---
885
+
886
+ def mentions_bot?(raw_text)
887
+ raw_text&.include?("<@#{@bot_user_id}>")
888
+ end
889
+
890
+ def strip_bot_mention(text)
891
+ text.gsub(/<@#{@bot_user_id}>\s*/, '')
892
+ end
893
+
894
+ # Dedup: returns true if this ts was already processed (within 60s window)
895
+ def dedup_event?(ts)
896
+ now = Time.now
897
+ @mutex.synchronize do
898
+ @processed_ts.delete_if { |_, t| now - t > 60 }
899
+ if @processed_ts.key?(ts)
900
+ true
901
+ else
902
+ @processed_ts[ts] = now
903
+ false
904
+ end
905
+ end
906
+ end
907
+
908
+ # Fetch raw messages from a thread. Returns an array of Slack message hashes.
909
+ def fetch_thread_messages(channel_id, thread_ts, since_ts: nil, exclude_ts: nil)
910
+ params = { channel: channel_id, ts: thread_ts, limit: 200 }
911
+ params[:oldest] = since_ts if since_ts
912
+ result = slack_get("conversations.replies", **params)
913
+ return [] unless result["ok"]
914
+
915
+ messages = result["messages"] || []
916
+ messages = messages.reject { |m| m["ts"] == exclude_ts }
917
+ messages
918
+ rescue => e
919
+ RailsConsoleAi.logger.warn("SlackBot: failed to fetch thread messages: #{e.message}")
920
+ []
921
+ end
922
+
923
+ # Log thread messages to stdout.
924
+ def log_thread_messages(messages, channel_id, thread_ts)
925
+ return if messages.empty?
926
+ tag = channel_log_tag(channel_id)
927
+ messages.each do |m|
928
+ name = m["user"] ? resolve_user_name(m["user"]) : (m["username"] || "bot")
929
+ text = unescape_slack(m["text"] || "")
930
+ text = strip_bot_mention(text).strip
931
+ next if text.empty?
932
+ puts "[#{channel_id}/#{thread_ts}]#{tag} (thread) @#{name}: #{text}"
933
+ end
934
+ end
935
+
936
+ # Build a context string from thread messages to prepend to the user's first message.
937
+ # Returns a formatted string, or nil if no messages.
938
+ def build_thread_context(messages)
939
+ return nil if messages.empty?
940
+
941
+ lines = messages.filter_map do |m|
942
+ text = unescape_slack(m["text"] || "")
943
+ text = strip_bot_mention(text).strip
944
+ next if text.empty?
945
+ name = if m["user"]
946
+ resolve_user_name(m["user"])
947
+ else
948
+ m["username"] || "bot"
949
+ end
950
+ "@#{name}: #{text}"
951
+ end
952
+ return nil if lines.empty?
953
+
954
+ "[You were tagged in an ongoing Slack thread. Here is the conversation so far:]\n\n" \
955
+ "#{lines.join("\n")}\n\n" \
956
+ "[End of thread context]"
957
+ end
958
+
622
959
  def log_startup
623
960
  channel_info = if @channel_ids && !@channel_ids.empty?
624
961
  "channels: #{@channel_ids.join(', ')}"
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.20.0'.freeze
2
+ VERSION = '0.21.0'.freeze
3
3
  end
@@ -93,10 +93,7 @@ module RailsConsoleAi
93
93
  conn = session_connection
94
94
  table = 'rails_console_ai_sessions'
95
95
 
96
- if conn.table_exists?(table)
97
- $stdout.puts "\e[32mRailsConsoleAi: #{table} already exists — checking for pending migrations.\e[0m"
98
- migrate!
99
- else
96
+ unless conn.table_exists?(table)
100
97
  conn.create_table(table) do |t|
101
98
  t.text :query, null: false
102
99
  t.text :conversation, null: false
@@ -112,6 +109,8 @@ module RailsConsoleAi
112
109
  t.string :provider, limit: 50
113
110
  t.string :model, limit: 100
114
111
  t.string :name, limit: 255
112
+ t.string :slack_thread_ts, limit: 255
113
+ t.string :slack_channel_name, limit: 255
115
114
  t.integer :duration_ms
116
115
  t.datetime :created_at, null: false
117
116
  end
@@ -119,9 +118,12 @@ module RailsConsoleAi
119
118
  conn.add_index(table, :created_at)
120
119
  conn.add_index(table, :user_name)
121
120
  conn.add_index(table, :name)
121
+ conn.add_index(table, :slack_thread_ts)
122
122
 
123
123
  $stdout.puts "\e[32mRailsConsoleAi: created #{table} table.\e[0m"
124
124
  end
125
+
126
+ migrate!
125
127
  rescue => e
126
128
  $stderr.puts "\e[31mRailsConsoleAi setup failed: #{e.class}: #{e.message}\e[0m"
127
129
  end
@@ -149,6 +151,11 @@ module RailsConsoleAi
149
151
  migrations << 'slack_thread_ts'
150
152
  end
151
153
 
154
+ unless conn.column_exists?(table, :slack_channel_name)
155
+ conn.add_column(table, :slack_channel_name, :string, limit: 255)
156
+ migrations << 'slack_channel_name'
157
+ end
158
+
152
159
  if migrations.empty?
153
160
  $stdout.puts "\e[32mRailsConsoleAi: #{table} is up to date.\e[0m"
154
161
  else
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.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr