rails_console_ai 0.20.0 → 0.22.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.
@@ -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
  )
@@ -121,6 +121,8 @@ 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
126
  { role: msg[:role].to_s, content: content }
125
127
  end
126
128
 
@@ -175,7 +177,9 @@ module RailsConsoleAi
175
177
 
176
178
  usage = response.usage
177
179
  if usage
178
- $stderr.puts "\e[36m[debug] response: #{response.stop_reason} | in: #{usage.input_tokens} out: #{usage.output_tokens}\e[0m"
180
+ cache_r = usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens : 'N/A'
181
+ cache_w = usage.respond_to?(:cache_write_input_tokens) ? usage.cache_write_input_tokens : 'N/A'
182
+ $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
183
  end
180
184
  end
181
185
  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(', ')}"