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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee0daa4b9926c6df19c9e2946f019b5d17dd3bd64d4e8998e92d86ed16dff8e1
4
- data.tar.gz: f336fe3abf34a513a5de12b138d69f4941e3cd65eeecaa8b548658d3da8b846e
3
+ metadata.gz: dba28b6d7543df66792877bddc51336f105c441a06b089de34ab0c7f89ca8f26
4
+ data.tar.gz: 4a54272fafd704abd003f06f2ab065d11f47b7c9a42b2f7c99c55b07b8712f8a
5
5
  SHA512:
6
- metadata.gz: 35d266cac783a3e43a6712380085215b7ba7503874b4f7a97ce6fac30875bded29e658d52dc4d1196bcb2e907e555255538ec6563e190212ea0bbcc6c02179d4
7
- data.tar.gz: 64e365d8d18a9a559bf9c8ec5b982f7ecff03d1faed9d731bc3734b6493cdeb7cab3055de5ae56da3e5d5112ccfb7b6ecf47b652a7a570c8fdb8401e0eddcf40
6
+ metadata.gz: 27c8413cc63c84c94d0fad464c3f3d6cd0b376a744122a2358b5899e0382f82e696a3e188647db952235986ee2a973c85b73065939a898cd7229955a61c377cf
7
+ data.tar.gz: d7e3041e7e37dd2ed304537bdfb02693647d3d32a9f1b6888718d9e38fcafe0c1b92000606c970716cd5074e7424b81e37d894d099b93ac6155268703ad54738
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.22.0]
6
+
7
+ - Fix blank content block handling in Bedrock provider
8
+ - Fix `recall_output` to expand in place and restore preview after LLM responds, preventing context bloat
9
+ - Remove safety guard bypass from prompt
10
+ - Fix issue where LLM couldn't recall multiple outputs at once
11
+
12
+ ## [0.21.0]
13
+
14
+ - Add Slack @mention support and channel name tracking
15
+ - Stop looping after user cancels execution
16
+ - Remove edit feature from executor
17
+ - Include more info in tool call log line
18
+ - Support class methods in `bypass_guards_for_methods`
19
+ - Rename setup tasks to `ai_db_setup` and `ai_db_migrate`
20
+ - Fix effective model resolution in multi-threaded Slack bot
21
+ - Fix cost tracking with prompt caching through Bedrock
22
+ - Add `/unthink` command
23
+ - Make `!think` / `/think` thread-safe for Slack
24
+ - Fix truncating console output
25
+ - Reduce cost by deferring large output until LLM requests it
26
+ - Add `!name`, `!model`, and `/model` commands to Slack and console
27
+
5
28
  ## [0.20.0]
6
29
 
7
30
  - Add per-user system prompt seeding
data/README.md CHANGED
@@ -12,7 +12,7 @@ irb> ai "find the 5 most recent orders over $100"
12
12
 
13
13
  Order.where("total > ?", 100).order(created_at: :desc).limit(5)
14
14
 
15
- Execute? [y/N/edit/danger] y
15
+ Execute? [y/N/danger] y
16
16
  => [#<Order id: 4821, ...>, ...]
17
17
  ```
18
18
 
@@ -62,7 +62,8 @@ end
62
62
  | `ai!` | Enter interactive mode (multi-turn conversation) |
63
63
  | `ai? "query"` | Explain only, no execution |
64
64
  | `ai_init` | Generate app guide for better AI context |
65
- | `ai_setup` | Install session logging table |
65
+ | `ai_db_setup` | Install session logging table + run migrations |
66
+ | `ai_db_migrate` | Run pending session table migrations |
66
67
  | `ai_sessions` | List recent sessions |
67
68
  | `ai_resume` | Resume a session by name or ID |
68
69
  | `ai_memories` | Show stored memories |
@@ -100,7 +101,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
100
101
  - **Skills** — predefined procedures with guard bypasses that the AI activates on demand
101
102
  - **Memories** — AI saves what it learns about your app across sessions
102
103
  - **App guide** — `ai_init` generates a guide injected into every system prompt
103
- - **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
104
+ - **Sessions** — name, list, and resume interactive conversations (`ai_db_setup` to enable)
104
105
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
105
106
  - **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
106
107
  - **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
@@ -213,7 +214,7 @@ Skills and global `bypass_guards_for_methods` coexist — use config-level bypas
213
214
  ### Toggling Safe Mode
214
215
 
215
216
  - **`/danger`** in interactive mode toggles all guards off/on for the session
216
- - **`d`** at the `Execute? [y/N/edit/danger]` prompt disables guards for that single execution
217
+ - **`d`** at the `Execute? [y/N/danger]` prompt disables guards for that single execution
217
218
  - When a guard blocks an operation, the user is prompted: `Re-run with safe mode disabled? [y/N]`
218
219
 
219
220
  ## LLM Providers
@@ -296,7 +297,7 @@ Timeout is automatically raised to 300s minimum for local models to account for
296
297
  RailsConsoleAi.configure do |config|
297
298
  config.provider = :anthropic # :anthropic, :openai, :bedrock, :local
298
299
  config.auto_execute = false # true to skip confirmations
299
- config.session_logging = true # requires ai_setup
300
+ config.session_logging = true # requires ai_db_setup
300
301
  config.temperature = 0.2
301
302
  config.timeout = 30 # HTTP timeout in seconds
302
303
  config.max_tool_rounds = 200 # safety cap on tool-use loops
@@ -363,11 +364,14 @@ Run RailsConsoleAi as a Slack bot. Each Slack thread becomes an independent AI s
363
364
  3. **Bot Token Scopes** — OAuth & Permissions → Bot Token Scopes, add:
364
365
  - `chat:write`
365
366
  - `channels:history` (public channels)
367
+ - `channels:read` (channel names in logs, optional)
366
368
  - `groups:history` (private channels, optional)
369
+ - `groups:read` (private channel names in logs, optional)
367
370
  - `im:history` (direct messages)
368
371
  - `users:read`
369
372
 
370
373
  4. **Event Subscriptions** — Event Subscriptions → toggle ON, then under "Subscribe to bot events" add:
374
+ - `app_mention` (respond when @mentioned in any channel)
371
375
  - `message.channels` (public channels)
372
376
  - `message.groups` (private channels, optional)
373
377
  - `message.im` (direct messages)
@@ -399,7 +403,12 @@ end
399
403
  bundle exec rake rails_console_ai:slack
400
404
  ```
401
405
 
402
- This starts a long-running process (run it separately from your web server). Each new message creates a session; threaded replies continue the conversation. The bot auto-executes code with safety guards always enabled — there is no `/danger` equivalent in Slack.
406
+ This starts a long-running process (run it separately from your web server). The bot auto-executes code with safety guards always enabled — there is no `/danger` equivalent in Slack.
407
+
408
+ **@mention behavior:**
409
+ - **DMs** — the bot responds to all messages, no @mention needed.
410
+ - **Channels** — the bot only responds when @mentioned. @mention it in any channel message or thread to start a session. The person who first @mentions the bot owns the session — only they can continue the conversation, and they must @mention the bot on each message. Exception: when the bot asks a question, the owner can reply without @mentioning.
411
+ - **Joining threads** — when @mentioned mid-thread, the bot reads the thread history for context so it understands what's already been discussed.
403
412
 
404
413
  ## Requirements
405
414
 
@@ -13,6 +13,7 @@
13
13
  <tr>
14
14
  <th>Time</th>
15
15
  <th>User</th>
16
+ <th>Channel</th>
16
17
  <th>Name</th>
17
18
  <th style="max-width: 400px;">Query</th>
18
19
  <th>Mode</th>
@@ -26,6 +27,7 @@
26
27
  <tr>
27
28
  <td class="mono"><%= session.created_at.strftime('%Y-%m-%d %H:%M') %></td>
28
29
  <td><%= session.user_name %></td>
30
+ <td><%= session.try(:slack_channel_name).presence || '-' %></td>
29
31
  <td><%= session.name.present? ? session.name : '-' %></td>
30
32
  <td class="query-cell"><a href="<%= rails_console_ai.session_path(session) %>" title="<%= h session.query.truncate(200) %>"><%= truncate(session.query.gsub(/\s+/, ' ').strip, length: 80) %></a></td>
31
33
  <td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
@@ -30,6 +30,12 @@
30
30
  <label>User</label>
31
31
  <span><%= @session.user_name || '-' %></span>
32
32
  </div>
33
+ <% if @session.try(:slack_channel_name).present? %>
34
+ <div class="meta-item">
35
+ <label>Channel</label>
36
+ <span><%= @session.slack_channel_name %></span>
37
+ </div>
38
+ <% end %>
33
39
  <div class="meta-item">
34
40
  <label>Provider / Model</label>
35
41
  <span><%= @session.provider %> / <%= @session.model %></span>
@@ -37,6 +37,33 @@ module RailsConsoleAi
37
37
  $stdout.puts
38
38
  end
39
39
 
40
+ def display_result_output(output)
41
+ text = output.to_s
42
+ return if text.strip.empty?
43
+
44
+ lines = text.lines
45
+ total_lines = lines.length
46
+ total_chars = text.length
47
+
48
+ if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
49
+ $stdout.print text
50
+ else
51
+ truncated = lines.first(MAX_DISPLAY_LINES).join
52
+ truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
53
+ $stdout.print truncated
54
+
55
+ omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
56
+ omitted_chars = [total_chars - truncated.length, 0].max
57
+ parts = []
58
+ parts << "#{omitted_lines} lines" if omitted_lines > 0
59
+ parts << "#{omitted_chars} chars" if omitted_chars > 0
60
+
61
+ @omitted_counter += 1
62
+ @omitted_outputs[@omitted_counter] = text
63
+ $stdout.puts colorize(" (output truncated, omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
64
+ end
65
+ end
66
+
40
67
  def display_result(result)
41
68
  full = "=> #{result.inspect}"
42
69
  lines = full.lines
@@ -255,8 +282,12 @@ module RailsConsoleAi
255
282
  @engine.display_conversation
256
283
  when '/cost'
257
284
  @engine.display_cost_summary
285
+ when '/model'
286
+ display_model_info
258
287
  when '/think'
259
288
  @engine.upgrade_to_thinking_model
289
+ when '/unthink'
290
+ @engine.downgrade_from_thinking_model
260
291
  when /\A\/expand/
261
292
  expand_id = input.sub('/expand', '').strip.to_i
262
293
  full_output = expand_output(expand_id)
@@ -320,6 +351,27 @@ module RailsConsoleAi
320
351
  end
321
352
  end
322
353
 
354
+ def display_model_info
355
+ config = RailsConsoleAi.configuration
356
+ model = @engine.effective_model
357
+ thinking = config.resolved_thinking_model
358
+ pricing = Configuration::PRICING[model]
359
+
360
+ @real_stdout.puts "\e[36m Model info:\e[0m"
361
+ @real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"
362
+ @real_stdout.puts "\e[2m Model: #{model}\e[0m"
363
+ @real_stdout.puts "\e[2m Thinking model: #{thinking}\e[0m"
364
+ @real_stdout.puts "\e[2m Max tokens: #{config.resolved_max_tokens}\e[0m"
365
+ if pricing
366
+ @real_stdout.puts "\e[2m Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out\e[0m"
367
+ if pricing[:cache_read]
368
+ @real_stdout.puts "\e[2m Cache pricing: $#{pricing[:cache_read] * 1_000_000}/M read, $#{pricing[:cache_write] * 1_000_000}/M write\e[0m"
369
+ end
370
+ end
371
+ @real_stdout.puts "\e[2m Bedrock region: #{config.bedrock_region}\e[0m" if config.provider == :bedrock
372
+ @real_stdout.puts "\e[2m Local URL: #{config.local_url}\e[0m" if config.provider == :local
373
+ end
374
+
323
375
  def handle_name_command(input)
324
376
  name = input.sub('/name', '').strip.gsub(/\A(['"])(.*)\1\z/, '\2')
325
377
  if name.empty?
@@ -344,7 +396,9 @@ module RailsConsoleAi
344
396
  @real_stdout.puts "\e[2m /danger Toggle safe mode (currently #{safe_status})\e[0m"
345
397
  @real_stdout.puts "\e[2m /safe Show safety guard status\e[0m"
346
398
  end
399
+ @real_stdout.puts "\e[2m /model Show provider, model, and pricing info\e[0m"
347
400
  @real_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
401
+ @real_stdout.puts "\e[2m /unthink Switch back to default model\e[0m"
348
402
  @real_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
349
403
  @real_stdout.puts "\e[2m /usage Show session token totals\e[0m"
350
404
  @real_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
@@ -130,10 +130,14 @@ module RailsConsoleAi
130
130
  nil
131
131
  end
132
132
 
133
- def ai_setup
133
+ def ai_db_setup
134
134
  RailsConsoleAi.setup!
135
135
  end
136
136
 
137
+ def ai_db_migrate
138
+ RailsConsoleAi.migrate!
139
+ end
140
+
137
141
  def ai_init
138
142
  require 'rails_console_ai/context_builder'
139
143
  require 'rails_console_ai/providers/base'
@@ -157,7 +161,8 @@ module RailsConsoleAi
157
161
  $stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
158
162
  $stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
159
163
  $stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
160
- $stderr.puts "\e[33m ai_setup - install session logging table\e[0m"
164
+ $stderr.puts "\e[33m ai_db_setup - install session logging table + run migrations\e[0m"
165
+ $stderr.puts "\e[33m ai_db_migrate- run pending session table migrations\e[0m"
161
166
  $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
162
167
  $stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
163
168
  return nil
@@ -82,9 +82,6 @@ module RailsConsoleAi
82
82
  (e.g. `api = SalesforceApi.new(step1)`).
83
83
  - If the user asks you to provide code for them to run later (not execute now), put it
84
84
  in a ```ruby code block in your text response.
85
- - Use `RailsConsoleAi.configuration.safety_guards.without_guards { }` to wrap any
86
- operation that should bypass safety guards (e.g. calling a known-safe admin method).
87
-
88
85
  You have skills — predefined procedures for specific operations. When a user's request
89
86
  matches a skill, call activate_skill first to load the recipe and enable its guard
90
87
  bypasses, then follow the recipe.
@@ -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
@@ -239,11 +242,16 @@ module RailsConsoleAi
239
242
  output_parts << "Return value: #{exec_result.inspect}" if exec_result
240
243
 
241
244
  result_str = output_parts.join("\n\n")
242
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
243
245
 
244
246
  context_msg = "User directly executed code: `#{raw_code}`"
245
- context_msg += "\n#{result_str}" unless output_parts.empty?
246
- output_id = output_parts.empty? ? nil : @executor.store_output(result_str)
247
+ if result_str.length > LARGE_OUTPUT_THRESHOLD
248
+ output_id = @executor.store_output(result_str)
249
+ preview = result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
250
+ context_msg += "\n#{preview}\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
251
+ elsif !output_parts.empty?
252
+ output_id = @executor.store_output(result_str)
253
+ context_msg += "\n#{result_str}"
254
+ end
247
255
  @history << { role: :user, content: context_msg, output_id: output_id }
248
256
 
249
257
  @interactive_query ||= "> #{raw_code}"
@@ -295,7 +303,6 @@ module RailsConsoleAi
295
303
  end
296
304
 
297
305
  if @executor.last_cancelled?
298
- @history << { role: :user, content: "User declined to execute the code." }
299
306
  :cancelled
300
307
  elsif @executor.last_safety_error
301
308
  exec_result = @executor.offer_danger_retry(code)
@@ -312,13 +319,18 @@ module RailsConsoleAi
312
319
  output_parts << "Return value: #{exec_result.inspect}" if exec_result
313
320
  unless output_parts.empty?
314
321
  result_str = output_parts.join("\n\n")
315
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
316
322
  output_id = @executor.store_output(result_str)
317
- @history << { role: :user, content: "Code was executed (safety override). #{result_str}", output_id: output_id }
323
+ context_msg = "Code was executed (safety override). "
324
+ if result_str.length > LARGE_OUTPUT_THRESHOLD
325
+ context_msg += result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
326
+ context_msg += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
327
+ else
328
+ context_msg += result_str
329
+ end
330
+ @history << { role: :user, content: context_msg, output_id: output_id }
318
331
  end
319
332
  :success
320
333
  else
321
- @history << { role: :user, content: "User declined to execute with safe mode disabled." }
322
334
  :cancelled
323
335
  end
324
336
  elsif @executor.last_error
@@ -335,9 +347,15 @@ module RailsConsoleAi
335
347
 
336
348
  unless output_parts.empty?
337
349
  result_str = output_parts.join("\n\n")
338
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
339
350
  output_id = @executor.store_output(result_str)
340
- @history << { role: :user, content: "Code was executed. #{result_str}", output_id: output_id }
351
+ context_msg = "Code was executed. "
352
+ if result_str.length > LARGE_OUTPUT_THRESHOLD
353
+ context_msg += result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
354
+ context_msg += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
355
+ else
356
+ context_msg += result_str
357
+ end
358
+ @history << { role: :user, content: context_msg, output_id: output_id }
341
359
  end
342
360
 
343
361
  :success
@@ -417,16 +435,36 @@ module RailsConsoleAi
417
435
 
418
436
  def upgrade_to_thinking_model
419
437
  config = RailsConsoleAi.configuration
420
- current = config.resolved_model
438
+ current = effective_model
421
439
  thinking = config.resolved_thinking_model
422
440
 
423
441
  if current == thinking
424
442
  $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
425
443
  else
426
- config.model = thinking
444
+ @model_override = thinking
427
445
  @provider = nil
428
446
  $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
429
447
  end
448
+ effective_model
449
+ end
450
+
451
+ def downgrade_from_thinking_model
452
+ config = RailsConsoleAi.configuration
453
+ default = config.resolved_model
454
+ current = effective_model
455
+
456
+ if current == default && @model_override.nil?
457
+ $stdout.puts "\e[36m Already using default model (#{current}).\e[0m"
458
+ else
459
+ @model_override = nil
460
+ @provider = nil
461
+ $stdout.puts "\e[36m Switched back to default model: #{default}\e[0m"
462
+ end
463
+ effective_model
464
+ end
465
+
466
+ def effective_model
467
+ @model_override || RailsConsoleAi.configuration.resolved_model
430
468
  end
431
469
 
432
470
  def compact_history
@@ -522,6 +560,8 @@ module RailsConsoleAi
522
560
  name: @session_name
523
561
  )
524
562
  log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
563
+ log_attrs[:slack_channel_name] = @slack_channel_name if @slack_channel_name
564
+ log_attrs[:model] = effective_model
525
565
  if @channel.user_identity
526
566
  log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
527
567
  end
@@ -558,6 +598,7 @@ module RailsConsoleAi
558
598
  start_time: @interactive_start
559
599
  }
560
600
  log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
601
+ log_attrs[:slack_channel_name] = @slack_channel_name if @slack_channel_name
561
602
  if @channel.user_identity
562
603
  log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
563
604
  end
@@ -591,6 +632,8 @@ module RailsConsoleAi
591
632
 
592
633
  This session has safety guards that block side effects (database writes, HTTP mutations, etc.).
593
634
  If an operation is blocked, the user will be prompted to allow it or disable guards.
635
+ Do NOT attempt to bypass or work around safety guards in your code — just write the
636
+ operation normally and let the safety system handle it.
594
637
  PROMPT
595
638
  end
596
639
  end
@@ -619,7 +662,15 @@ module RailsConsoleAi
619
662
  end
620
663
 
621
664
  def provider
622
- @provider ||= Providers.build
665
+ @provider ||= begin
666
+ if @model_override
667
+ config = RailsConsoleAi.configuration.dup
668
+ config.model = @model_override
669
+ Providers.build(config)
670
+ else
671
+ Providers.build
672
+ end
673
+ end
623
674
  end
624
675
 
625
676
  def context_builder
@@ -736,6 +787,10 @@ module RailsConsoleAi
736
787
  @channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
737
788
  end
738
789
 
790
+ # Trim old tool outputs between rounds to prevent context explosion.
791
+ # The LLM can still retrieve omitted outputs via recall_output.
792
+ messages = trim_old_outputs(messages) if round > 0
793
+
739
794
  if RailsConsoleAi.configuration.debug
740
795
  debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
741
796
  end
@@ -767,6 +822,28 @@ module RailsConsoleAi
767
822
  last_tool_names = result.tool_calls.map { |tc| tc[:name] }
768
823
  result.tool_calls.each do |tc|
769
824
  break if @channel.cancelled?
825
+
826
+ # Intercept recall_output/recall_outputs: expand in place instead of adding large messages
827
+ if tc[:name] == 'recall_output' || tc[:name] == 'recall_outputs'
828
+ ids = if tc[:name] == 'recall_outputs'
829
+ Array(tc[:arguments]['ids']).map(&:to_i)
830
+ else
831
+ [tc[:arguments]['id'].to_i]
832
+ end
833
+ @channel.display_tool_call("#{tc[:name]}(#{ids.join(', ')})")
834
+ expanded = expand_outputs_in_place(messages, ids)
835
+ tool_result = if expanded.any?
836
+ "Expanded #{expanded.length} output(s) in conversation. The full content is now visible in the original message(s) above."
837
+ else
838
+ "No matching outputs found with id(s) #{ids.join(', ')}."
839
+ end
840
+ @channel.display_dim(" #{tool_result}")
841
+ tool_msg = provider.format_tool_result(tc[:id], tool_result)
842
+ messages << tool_msg
843
+ new_messages << tool_msg
844
+ next
845
+ end
846
+
770
847
  if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
771
848
  # Display any pending LLM text before prompting the user
772
849
  if last_thinking
@@ -790,16 +867,32 @@ module RailsConsoleAi
790
867
  end
791
868
 
792
869
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
793
- if tool_result.to_s.length > 200
794
- tool_msg[:output_id] = @executor.store_output(tool_result.to_s)
870
+ full_text = tool_result.to_s
871
+ if full_text.length > LARGE_OUTPUT_THRESHOLD
872
+ output_id = @executor.store_output(full_text)
873
+ tool_msg[:output_id] = output_id
874
+ truncated = full_text[0, LARGE_OUTPUT_PREVIEW_CHARS]
875
+ 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]"
876
+ tool_msg = provider.format_tool_result(tc[:id], truncated)
877
+ tool_msg[:output_id] = output_id
878
+ elsif full_text.length > 200
879
+ tool_msg[:output_id] = @executor.store_output(full_text)
795
880
  end
796
881
  messages << tool_msg
797
882
  new_messages << tool_msg
798
883
  end
799
884
 
885
+ # If the user declined execution, don't call the LLM again —
886
+ # just return to the prompt so they can correct their request.
887
+ break if @executor.last_cancelled?
888
+
800
889
  exhausted = true if round == max_rounds - 1
801
890
  end
802
891
 
892
+ # Re-truncate any outputs that were expanded for the LLM — the LLM has
893
+ # seen them and responded, so collapse back to save context on future calls.
894
+ re_truncate_expanded(messages)
895
+
803
896
  if exhausted
804
897
  $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
805
898
  messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
@@ -821,7 +914,7 @@ module RailsConsoleAi
821
914
  @total_input_tokens += result.input_tokens || 0
822
915
  @total_output_tokens += result.output_tokens || 0
823
916
 
824
- model = RailsConsoleAi.configuration.resolved_model
917
+ model = effective_model
825
918
  @token_usage[model][:input] += result.input_tokens || 0
826
919
  @token_usage[model][:output] += result.output_tokens || 0
827
920
  @token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
@@ -865,7 +958,8 @@ module RailsConsoleAi
865
958
  attrs.merge(
866
959
  input_tokens: @total_input_tokens,
867
960
  output_tokens: @total_output_tokens,
868
- duration_ms: duration_ms
961
+ duration_ms: duration_ms,
962
+ model: effective_model
869
963
  )
870
964
  )
871
965
  end
@@ -896,6 +990,8 @@ module RailsConsoleAi
896
990
  when 'save_memory' then "(\"#{args['name']}\")"
897
991
  when 'delete_memory' then "(\"#{args['name']}\")"
898
992
  when 'recall_memories' then args['query'] ? "(\"#{args['query']}\")" : ''
993
+ when 'activate_skill' then "(\"#{args['name']}\")"
994
+ when 'recall_output' then "(#{args['id']})"
899
995
  when 'execute_plan'
900
996
  steps = args['steps']
901
997
  steps ? "(#{steps.length} steps)" : ''
@@ -1021,7 +1117,7 @@ module RailsConsoleAi
1021
1117
 
1022
1118
  input_t = result.input_tokens || 0
1023
1119
  output_t = result.output_tokens || 0
1024
- model = RailsConsoleAi.configuration.resolved_model
1120
+ model = effective_model
1025
1121
  pricing = Configuration::PRICING[model]
1026
1122
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1027
1123
 
@@ -1059,16 +1155,14 @@ module RailsConsoleAi
1059
1155
  .select { |m, _| m[:output_id] }
1060
1156
  .map { |_, i| i }
1061
1157
 
1062
- if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
1063
- return messages.map { |m| m.except(:output_id) }
1064
- end
1158
+ return messages if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
1065
1159
 
1066
1160
  trim_indices = output_indices[0..-(RECENT_OUTPUTS_TO_KEEP + 1)]
1067
1161
  messages.each_with_index.map do |msg, i|
1068
1162
  if trim_indices.include?(i)
1069
1163
  trim_message(msg)
1070
1164
  else
1071
- msg.except(:output_id)
1165
+ msg
1072
1166
  end
1073
1167
  end
1074
1168
  end
@@ -1084,12 +1178,52 @@ module RailsConsoleAi
1084
1178
  block
1085
1179
  end
1086
1180
  end
1087
- { role: msg[:role], content: trimmed_content }
1181
+ msg.merge(content: trimmed_content)
1088
1182
  elsif msg[:role].to_s == 'tool'
1089
- msg.except(:output_id).merge(content: ref)
1183
+ msg.merge(content: ref)
1090
1184
  else
1091
1185
  first_line = msg[:content].to_s.lines.first&.strip || msg[:content]
1092
- { role: msg[:role], content: "#{first_line}\n#{ref}" }
1186
+ msg.merge(content: "#{first_line}\n#{ref}")
1187
+ end
1188
+ end
1189
+
1190
+ def expand_outputs_in_place(messages, ids)
1191
+ expanded = []
1192
+ messages.each do |msg|
1193
+ next unless msg[:output_id] && ids.include?(msg[:output_id])
1194
+ full_output = @executor.recall_output(msg[:output_id])
1195
+ next unless full_output
1196
+ # Save original content so re_truncate_expanded can restore it
1197
+ msg[:pre_expand_content] = msg[:content]
1198
+ # Replace content with full output (handle Anthropic, OpenAI, and user message formats)
1199
+ if msg[:content].is_a?(Array)
1200
+ msg[:content] = msg[:content].map do |block|
1201
+ if block.is_a?(Hash) && block['type'] == 'tool_result'
1202
+ block.merge('content' => full_output)
1203
+ else
1204
+ block
1205
+ end
1206
+ end
1207
+ elsif msg[:role].to_s == 'tool'
1208
+ msg[:content] = full_output
1209
+ else
1210
+ # User messages (e.g., direct execution) — preserve first line, replace rest
1211
+ first_line = msg[:content].to_s.lines.first&.chomp || ''
1212
+ msg[:content] = "#{first_line}\n#{full_output}"
1213
+ end
1214
+ msg[:expanded] = true
1215
+ expanded << msg[:output_id]
1216
+ end
1217
+ expanded
1218
+ end
1219
+
1220
+ # Restore messages that were temporarily expanded back to their original
1221
+ # (preview/truncated) content. Called after the LLM has seen the expanded
1222
+ # content and responded.
1223
+ def re_truncate_expanded(messages)
1224
+ messages.each do |msg|
1225
+ next unless msg.delete(:expanded)
1226
+ msg[:content] = msg.delete(:pre_expand_content)
1093
1227
  end
1094
1228
  end
1095
1229