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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +15 -6
- data/app/views/rails_console_ai/sessions/index.html.erb +2 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/lib/rails_console_ai/channel/console.rb +54 -0
- data/lib/rails_console_ai/console_methods.rb +7 -2
- data/lib/rails_console_ai/context_builder.rb +0 -3
- data/lib/rails_console_ai/conversation_engine.rb +159 -25
- data/lib/rails_console_ai/executor.rb +9 -29
- data/lib/rails_console_ai/providers/bedrock.rb +7 -3
- data/lib/rails_console_ai/safety_guards.rb +16 -2
- data/lib/rails_console_ai/session_logger.rb +2 -1
- data/lib/rails_console_ai/slack_bot.rb +363 -26
- data/lib/rails_console_ai/tools/registry.rb +14 -1
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +11 -4
- metadata +1 -1
|
@@ -100,8 +100,13 @@ module RailsConsoleAi
|
|
|
100
100
|
@last_safety_exception = nil
|
|
101
101
|
captured_output = StringIO.new
|
|
102
102
|
old_stdout = $stdout
|
|
103
|
-
#
|
|
104
|
-
|
|
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/
|
|
349
|
+
"Execute? [y/N/danger] "
|
|
370
350
|
else
|
|
371
|
-
"Execute? [y/N
|
|
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?(:
|
|
76
|
-
cache_write_input_tokens: usage.respond_to?(:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
353
|
-
|
|
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
|
-
|
|
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(', ')}"
|