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 +4 -4
- data/CHANGELOG.md +16 -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/conversation_engine.rb +62 -11
- data/lib/rails_console_ai/executor.rb +9 -29
- data/lib/rails_console_ai/providers/bedrock.rb +5 -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/version.rb +1 -1
- data/lib/rails_console_ai.rb +11 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8faec693c3d3c83dafcaa65437081d5ba8e235f04527a28182104c4ecba5b80d
|
|
4
|
+
data.tar.gz: 171445a3ddc0c50093b1688260545df1558548a29cb81a6d3262d15d8ec4cf3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
-
| `
|
|
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 (`
|
|
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/
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 ||=
|
|
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
|
-
|
|
794
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
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
|
)
|
|
@@ -175,7 +175,9 @@ module RailsConsoleAi
|
|
|
175
175
|
|
|
176
176
|
usage = response.usage
|
|
177
177
|
if usage
|
|
178
|
-
|
|
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
|
-
|
|
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(', ')}"
|
data/lib/rails_console_ai.rb
CHANGED
|
@@ -93,10 +93,7 @@ module RailsConsoleAi
|
|
|
93
93
|
conn = session_connection
|
|
94
94
|
table = 'rails_console_ai_sessions'
|
|
95
95
|
|
|
96
|
-
|
|
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
|