rails_console_ai 0.35.0 → 0.38.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +102 -10
  4. data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
  5. data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
  6. data/app/models/rails_console_ai/artifact.rb +231 -0
  7. data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
  8. data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
  9. data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
  10. data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
  11. data/config/routes.rb +8 -0
  12. data/lib/generators/rails_console_ai/templates/initializer.rb +12 -1
  13. data/lib/rails_console_ai/channel/console.rb +132 -18
  14. data/lib/rails_console_ai/channel/slack.rb +1 -1
  15. data/lib/rails_console_ai/configuration.rb +114 -15
  16. data/lib/rails_console_ai/context_builder.rb +48 -18
  17. data/lib/rails_console_ai/conversation_engine.rb +231 -61
  18. data/lib/rails_console_ai/line_editor.rb +142 -0
  19. data/lib/rails_console_ai/providers/anthropic.rb +3 -1
  20. data/lib/rails_console_ai/providers/base.rb +26 -1
  21. data/lib/rails_console_ai/providers/bedrock.rb +5 -2
  22. data/lib/rails_console_ai/providers/local.rb +17 -37
  23. data/lib/rails_console_ai/providers/openai.rb +31 -10
  24. data/lib/rails_console_ai/providers/openrouter.rb +108 -0
  25. data/lib/rails_console_ai/slack_bot.rb +58 -23
  26. data/lib/rails_console_ai/slash_commands.rb +150 -0
  27. data/lib/rails_console_ai/sub_agent.rb +35 -2
  28. data/lib/rails_console_ai/tools/registry.rb +233 -3
  29. data/lib/rails_console_ai/version.rb +1 -1
  30. data/lib/rails_console_ai.rb +223 -0
  31. metadata +8 -1
@@ -1,3 +1,5 @@
1
+ require 'rails_console_ai/slash_commands'
2
+
1
3
  module RailsConsoleAi
2
4
  class ConversationEngine
3
5
  attr_reader :history, :total_input_tokens, :total_output_tokens,
@@ -11,6 +13,7 @@ module RailsConsoleAi
11
13
  REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
12
14
  REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
13
15
  CONTEXT_WARN_FRACTION = 0.7 # share of the model's context window → suggest /compact
16
+ MAX_TRUNCATION_RETRIES = 1 # responses cut off at the output cap → nudge, then stop
14
17
 
15
18
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
16
19
  @binding_context = binding_context
@@ -19,6 +22,7 @@ module RailsConsoleAi
19
22
  @slack_channel_name = slack_channel_name
20
23
  @executor = Executor.new(binding_context, channel: channel)
21
24
  @provider = nil
25
+ @routing_session_id = SecureRandom.hex(8)
22
26
  @context_builder = nil
23
27
  @context = nil
24
28
  @history = []
@@ -128,6 +132,16 @@ module RailsConsoleAi
128
132
  end
129
133
  end
130
134
 
135
+ # Creates the session row before the first turn runs, so its id can be
136
+ # handed out (the Slack bot links to it) while that turn is still going.
137
+ # Returns nil when session logging is off.
138
+ def ensure_session_record(query)
139
+ init_interactive unless @interactive_start
140
+ @interactive_query ||= query
141
+ log_interactive_turn(status: 'running') unless @interactive_session_id
142
+ @interactive_session_id
143
+ end
144
+
131
145
  def init_guide
132
146
  storage = RailsConsoleAi.storage
133
147
  existing_guide = begin
@@ -255,6 +269,80 @@ module RailsConsoleAi
255
269
  execute_direct(code)
256
270
  end
257
271
 
272
+ # --- Slash-command invocation of skills and agents ---
273
+
274
+ # A skill is a recipe for *this* assistant, and its guard bypasses have to
275
+ # land on the live executor — so it runs as a normal turn with the recipe
276
+ # prepended, not in a sub-agent. Returns the user-turn text to send.
277
+ def skill_command_prompt(skill, request)
278
+ bypass_methods = Array(skill['bypass_guards_for_methods'])
279
+ @executor.activate_skill_bypasses(bypass_methods) unless bypass_methods.empty?
280
+
281
+ if skill['source'] == :db && skill['id']
282
+ RailsConsoleAi::Skill.record_use!(skill['id'])
283
+ end
284
+
285
+ @channel.display_status(" Activated skill: #{skill['name']}")
286
+
287
+ request = request.to_s.strip
288
+ task = request.empty? ? "Follow this skill now." : request
289
+
290
+ "The user invoked the \"#{skill['name']}\" skill. Follow its procedure.\n\n" \
291
+ "--- SKILL: #{skill['name']} ---\n#{skill['body']}\n--- END SKILL ---\n\n" \
292
+ "Their request: #{task}"
293
+ end
294
+
295
+ # An agent is a separate context by definition, so this mirrors delegate_task:
296
+ # run it, then fold only its summary back into the conversation.
297
+ def run_agent_command(agent, task)
298
+ require 'rails_console_ai/sub_agent'
299
+
300
+ task = task.to_s.strip
301
+ if task.empty?
302
+ @channel.display_warning(" /#{SlashCommands.slugify(agent['name'])} needs a task. Try: /#{SlashCommands.slugify(agent['name'])} <what to investigate>")
303
+ return
304
+ end
305
+
306
+ @interactive_query ||= "/#{SlashCommands.slugify(agent['name'])} #{task}"
307
+ log_interactive_turn(status: 'running')
308
+
309
+ if agent['source'] == :db && agent['id']
310
+ RailsConsoleAi::Agent.record_use!(agent['id'])
311
+ end
312
+
313
+ sub = SubAgent.new(
314
+ task: task,
315
+ agent_config: agent,
316
+ binding_context: @executor.binding_context,
317
+ parent_channel: @channel,
318
+ executor: @executor
319
+ )
320
+
321
+ begin
322
+ # Deliberately NOT wrapped in @channel.wrap_llm_call. That wrapper puts the
323
+ # terminal in raw mode to watch for Esc, which clears ONLCR — so anything
324
+ # printed inside it staircases down the screen. Everywhere else it wraps
325
+ # only the provider HTTP call, where nothing prints; a sub-agent run prints
326
+ # throughout. Ctrl-C still lands here as an Interrupt.
327
+ result = sub.run
328
+ rescue Interrupt
329
+ @channel.display_warning(" Cancelled.")
330
+ log_interactive_turn(status: 'ready')
331
+ return
332
+ end
333
+
334
+ record_sub_agent_usage(sub)
335
+
336
+ @history << {
337
+ role: :user,
338
+ content: "The user ran the \"#{agent['name']}\" agent with the task: #{task}\n\n" \
339
+ "The agent reported back:\n#{result}"
340
+ }
341
+
342
+ @channel.display(result.to_s)
343
+ log_interactive_turn(status: 'ready')
344
+ end
345
+
258
346
  def execute_direct(raw_code)
259
347
  @interactive_query ||= "> #{raw_code}"
260
348
  log_interactive_turn(status: 'running')
@@ -424,6 +512,7 @@ module RailsConsoleAi
424
512
  end
425
513
 
426
514
  total_cost = 0.0
515
+ has_reported_cost = false
427
516
  $stdout.puts "\e[36m Cost estimate:\e[0m"
428
517
 
429
518
  @token_usage.each do |model, usage|
@@ -432,13 +521,25 @@ module RailsConsoleAi
432
521
  input_str = "in: #{format_tokens(usage[:input])}"
433
522
  output_str = "out: #{format_tokens(usage[:output])}"
434
523
 
435
- if pricing
524
+ # A provider that reports real dollars (OpenRouter) beats any estimate.
525
+ reported_cost = usage[:cost]
526
+ if reported_cost && reported_cost > 0
527
+ has_reported_cost = true
528
+ total_cost += reported_cost
529
+ cache_str = ""
436
530
  cache_read = usage[:cache_read] || 0
437
531
  cache_write = usage[:cache_write] || 0
438
- cost = usage_cost(pricing, input: usage[:input], output: usage[:output],
439
- cache_read: cache_read, cache_write: cache_write)
532
+ cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
533
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}\e[0m"
534
+ elsif pricing
535
+ cost = Configuration.estimate_cost(model,
536
+ input: usage[:input], output: usage[:output],
537
+ cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
538
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
440
539
  total_cost += cost
441
540
  cache_str = ""
541
+ cache_read = usage[:cache_read] || 0
542
+ cache_write = usage[:cache_write] || 0
442
543
  cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
443
544
  $stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}\e[0m"
444
545
  else
@@ -446,7 +547,8 @@ module RailsConsoleAi
446
547
  end
447
548
  end
448
549
 
449
- $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
550
+ label = has_reported_cost ? "Total:" : "Total: ~"
551
+ $stdout.puts "\e[36m #{label}$#{'%.2f' % total_cost}\e[0m"
450
552
  end
451
553
 
452
554
  def display_conversation
@@ -505,38 +607,56 @@ module RailsConsoleAi
505
607
  upgrade_to_thinking_model
506
608
  end
507
609
 
508
- def upgrade_to_thinking_model
509
- config = RailsConsoleAi.configuration
510
- current = effective_model
511
- thinking = config.resolved_thinking_model
610
+ # Three tiers a session can run on. :default and :thinking usually share a
611
+ # model and differ in effort, so a switch is tracked by tier, not by model ID.
612
+ MODEL_TIERS = %i[default thinking cheap].freeze
512
613
 
513
- if current == thinking
514
- $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
515
- else
516
- @model_override = thinking
517
- @provider = nil
518
- $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
519
- end
520
- effective_model
614
+ def upgrade_to_thinking_model
615
+ switch_model_tier(:thinking)
521
616
  end
522
617
 
523
618
  def downgrade_from_thinking_model
524
- config = RailsConsoleAi.configuration
525
- default = config.resolved_model
526
- current = effective_model
619
+ switch_model_tier(:default)
620
+ end
621
+
622
+ def switch_to_cheap_model
623
+ switch_model_tier(:cheap)
624
+ end
527
625
 
528
- if current == default && @model_override.nil?
529
- $stdout.puts "\e[36m Already using default model (#{current}).\e[0m"
626
+ def switch_model_tier(tier)
627
+ raise ArgumentError, "Unknown model tier: #{tier.inspect}" unless MODEL_TIERS.include?(tier)
628
+
629
+ if tier == model_tier
630
+ $stdout.puts "\e[36m Already using #{tier} model (#{effective_model_label}).\e[0m"
530
631
  else
531
- @model_override = nil
632
+ @model_tier = tier
532
633
  @provider = nil
533
- $stdout.puts "\e[36m Switched back to default model: #{default}\e[0m"
634
+ $stdout.puts "\e[36m Switched to #{tier} model: #{effective_model_label}\e[0m"
534
635
  end
535
636
  effective_model
536
637
  end
537
638
 
639
+ def model_tier
640
+ @model_tier || :default
641
+ end
642
+
538
643
  def effective_model
539
- @model_override || RailsConsoleAi.configuration.resolved_model
644
+ config = RailsConsoleAi.configuration
645
+ case model_tier
646
+ when :thinking then config.resolved_thinking_model
647
+ when :cheap then config.resolved_cheap_model
648
+ else config.resolved_model
649
+ end
650
+ end
651
+
652
+ def effective_effort
653
+ tier_config.resolved_effort
654
+ end
655
+
656
+ # "claude-opus-5-5 (high effort)", or the bare model when no effort is sent.
657
+ def effective_model_label
658
+ effort = effective_effort
659
+ effort ? "#{effective_model} (#{effort} effort)" : effective_model
540
660
  end
541
661
 
542
662
  def compact_history
@@ -653,6 +773,9 @@ module RailsConsoleAi
653
773
  log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
654
774
  end
655
775
  @interactive_session_id = SessionLogger.log(log_attrs)
776
+ # Tools that persist something (save_artifact) read this to point back at
777
+ # the conversation that produced it, without needing a handle on the engine.
778
+ RailsConsoleAi.current_session_id = @interactive_session_id
656
779
  end
657
780
  end
658
781
 
@@ -695,6 +818,19 @@ module RailsConsoleAi
695
818
 
696
819
  private
697
820
 
821
+ def record_sub_agent_usage(sub)
822
+ absorb_sub_agent_usage(input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost)
823
+ end
824
+
825
+ def absorb_sub_agent_usage(usage)
826
+ @total_input_tokens += usage[:input] || 0
827
+ @total_output_tokens += usage[:output] || 0
828
+ return unless usage[:model]
829
+ @token_usage[usage[:model]][:input] += usage[:input] || 0
830
+ @token_usage[usage[:model]][:output] += usage[:output] || 0
831
+ @token_usage[usage[:model]][:cost] = (@token_usage[usage[:model]][:cost] || 0) + usage[:cost] if usage[:cost]
832
+ end
833
+
698
834
  def safety_context
699
835
  guards = RailsConsoleAi.configuration.safety_guards
700
836
  return nil if guards.empty?
@@ -750,16 +886,24 @@ module RailsConsoleAi
750
886
 
751
887
  def provider
752
888
  @provider ||= begin
753
- if @model_override
754
- config = RailsConsoleAi.configuration.dup
755
- config.model = @model_override
756
- Providers.build(config)
757
- else
758
- Providers.build
759
- end
889
+ p = model_tier == :default ? Providers.build : Providers.build(tier_config)
890
+ p.routing_session_id = @routing_session_id if p.respond_to?(:routing_session_id=)
891
+ p
760
892
  end
761
893
  end
762
894
 
895
+ # The configuration for the current tier. Off the default tier it is a copy,
896
+ # so a switch never leaks into the global singleton or other sessions.
897
+ def tier_config
898
+ config = RailsConsoleAi.configuration
899
+ return config if model_tier == :default
900
+
901
+ config = config.dup
902
+ config.model = effective_model
903
+ config.effort = model_tier == :thinking ? config.thinking_effort : nil
904
+ config
905
+ end
906
+
763
907
  def context_builder
764
908
  @context_builder ||= ContextBuilder.new(channel_mode: @channel.mode, user_name: @channel.user_identity)
765
909
  end
@@ -880,6 +1024,7 @@ module RailsConsoleAi
880
1024
 
881
1025
  exhausted = false
882
1026
  wrap_up_reason = nil
1027
+ truncation_nudges = 0
883
1028
  tool_call_counts = Hash.new(0)
884
1029
  error_sig_counts = Hash.new(0)
885
1030
  warned_error_sigs = Set.new
@@ -939,6 +1084,34 @@ module RailsConsoleAi
939
1084
  debug_post_call(round, result, @total_input_tokens + total_input, @total_output_tokens + total_output)
940
1085
  end
941
1086
 
1087
+ # A response cut off at the output cap has nothing usable in it: the tool
1088
+ # call it was in the middle of writing arrives with half its arguments, so
1089
+ # it is discarded, and only the sentence the model wrote before starting
1090
+ # the call survives. Left unhandled that looks exactly like a finished
1091
+ # turn, and the model retries the same oversized call every time it is
1092
+ # asked again — the identical truncation, forever, at full output cost.
1093
+ # Say so, once, with the way out; the second one ends the turn.
1094
+ if result.truncated?
1095
+ cap = RailsConsoleAi.configuration.resolved_max_tokens
1096
+ @channel.display_warning(" Response hit the #{format_tokens(cap)} output-token cap mid-generation — the incomplete tool call was discarded.")
1097
+ if truncation_nudges >= MAX_TRUNCATION_RETRIES
1098
+ @channel.display_error(" Truncated again. Stopping — see the guidance above, or raise c.max_tokens.")
1099
+ break
1100
+ end
1101
+
1102
+ truncation_nudges += 1
1103
+ add_nudge.call(
1104
+ "Your last response was cut off at the #{cap}-token output limit, so the tool call you were " \
1105
+ "writing was discarded and never ran. Retrying it unchanged will be cut off at exactly the same " \
1106
+ "point. You must make the next response SMALLER. If you were passing a large block of content as " \
1107
+ "a tool argument — the content of an artifact, a file, a long report — do not inline it: run " \
1108
+ "execute_code and have the Ruby build the string and save it directly, e.g. " \
1109
+ "`RailsConsoleAi.save_artifact(name: \"...\", content: some_string)`, which never sends those " \
1110
+ "bytes through your response at all. Otherwise, split the work across several smaller tool calls."
1111
+ )
1112
+ next
1113
+ end
1114
+
942
1115
  break unless result.tool_use?
943
1116
 
944
1117
  last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
@@ -994,13 +1167,7 @@ module RailsConsoleAi
994
1167
 
995
1168
  # Aggregate sub-agent token usage into parent's cost tracking
996
1169
  if tc[:name] == 'delegate_task' && tools.last_sub_agent_usage
997
- sa = tools.last_sub_agent_usage
998
- @total_input_tokens += sa[:input] || 0
999
- @total_output_tokens += sa[:output] || 0
1000
- if sa[:model]
1001
- @token_usage[sa[:model]][:input] += sa[:input] || 0
1002
- @token_usage[sa[:model]][:output] += sa[:output] || 0
1003
- end
1170
+ absorb_sub_agent_usage(tools.last_sub_agent_usage)
1004
1171
  end
1005
1172
 
1006
1173
  if RailsConsoleAi.configuration.debug
@@ -1109,6 +1276,9 @@ module RailsConsoleAi
1109
1276
  total_output += result.output_tokens || 0
1110
1277
  total_cache_read += result.cache_read_input_tokens || 0
1111
1278
  total_cache_write += result.cache_write_input_tokens || 0
1279
+ if result.truncated?
1280
+ @channel.display_warning(" The final answer was cut off at the #{format_tokens(RailsConsoleAi.configuration.resolved_max_tokens)} output-token cap — what follows is incomplete.")
1281
+ end
1112
1282
  end
1113
1283
 
1114
1284
  last_llm_stats = result ? format_llm_stats(result) : nil
@@ -1134,6 +1304,7 @@ module RailsConsoleAi
1134
1304
  @token_usage[model][:output] += result.output_tokens || 0
1135
1305
  @token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
1136
1306
  @token_usage[model][:cache_write] = (@token_usage[model][:cache_write] || 0) + (result.cache_write_input_tokens || 0)
1307
+ @token_usage[model][:cost] = (@token_usage[model][:cost] || 0) + (result.cost || 0) if result.cost
1137
1308
  end
1138
1309
 
1139
1310
  def display_usage(result, show_session: false)
@@ -1196,18 +1367,6 @@ module RailsConsoleAi
1196
1367
  chars / 4
1197
1368
  end
1198
1369
 
1199
- # The four usage buckets each bill at their own rate. `input_tokens` from the
1200
- # API is the UNCACHED remainder — cached tokens are reported separately and are
1201
- # not part of it (total prompt size is input + cache_read + cache_write), so
1202
- # discounting cache_read out of input double-counts and can drive a cost
1203
- # negative. Every cost readout goes through here.
1204
- def usage_cost(pricing, input:, output:, cache_read: 0, cache_write: 0)
1205
- ((input || 0) * pricing[:input]) +
1206
- ((output || 0) * pricing[:output]) +
1207
- ((cache_read || 0) * (pricing[:cache_read] || 0)) +
1208
- ((cache_write || 0) * (pricing[:cache_write] || 0))
1209
- end
1210
-
1211
1370
  def format_tokens(count)
1212
1371
  if count >= 1_000_000
1213
1372
  "#{(count / 1_000_000.0).round(1)}M"
@@ -1559,13 +1718,17 @@ module RailsConsoleAi
1559
1718
  cache_r = result.cache_read_input_tokens || 0
1560
1719
  cache_w = result.cache_write_input_tokens || 0
1561
1720
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1562
- model = effective_model
1563
- pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1564
- if pricing
1565
- cost = usage_cost(pricing, input: result.input_tokens, output: result.output_tokens,
1566
- cache_read: cache_r, cache_write: cache_w)
1567
- parts << "~$#{'%.4f' % cost}"
1721
+
1722
+ if result.cost
1723
+ parts << "$#{'%.4f' % result.cost}"
1724
+ else
1725
+ cost = Configuration.estimate_cost(effective_model,
1726
+ input: result.input_tokens || 0, output: result.output_tokens || 0,
1727
+ cache_read: cache_r, cache_write: cache_w,
1728
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1729
+ parts << "~$#{'%.4f' % cost}" if cost
1568
1730
  end
1731
+
1569
1732
  parts.join(' | ')
1570
1733
  end
1571
1734
 
@@ -1597,11 +1760,18 @@ module RailsConsoleAi
1597
1760
  parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
1598
1761
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1599
1762
 
1600
- if pricing
1601
- cost = usage_cost(pricing, input: input_t, output: output_t,
1602
- cache_read: cache_r, cache_write: cache_w)
1603
- session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1604
- parts << "~$#{'%.4f' % cost}"
1763
+ ttl = RailsConsoleAi.configuration.resolved_cache_ttl
1764
+ if result.cost
1765
+ parts << "$#{'%.4f' % result.cost}"
1766
+ session_has_reported = @token_usage.any? { |_, u| u[:cost] && u[:cost] > 0 }
1767
+ session_cost = session_has_reported ? @token_usage.sum { |_, u| u[:cost] || 0 } :
1768
+ Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
1769
+ $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: $#{'%.4f' % session_cost})#{r}"
1770
+ elsif pricing
1771
+ cost = Configuration.estimate_cost(model, input: input_t, output: output_t,
1772
+ cache_read: cache_r, cache_write: cache_w, cache_ttl: ttl)
1773
+ parts << "~$#{'%.4f' % cost}" if cost
1774
+ session_cost = Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
1605
1775
  $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1606
1776
  else
1607
1777
  $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
@@ -0,0 +1,142 @@
1
+ module RailsConsoleAi
2
+ # Thin adapters over the two line editors we can drive, so the interactive loop
3
+ # doesn't care which one is underneath.
4
+ #
5
+ # Reline (bundled with Ruby >= 2.7) is preferred: it renders a live completion
6
+ # dropdown as you type, which is what makes "/" discoverable. Readline is the
7
+ # fallback and only offers Tab completion.
8
+ #
9
+ # The two differ in four ways that matter here, all absorbed by the adapters:
10
+ # - prompt escaping: Readline needs \001..\002 around ANSI so it can compute
11
+ # the prompt width; Reline parses ANSI itself and would print those literally.
12
+ # - output: Reline writes through a Ruby IO, so it has to be pointed at the real
13
+ # stdout — otherwise the dropdown's escape codes land in the captured session log.
14
+ # Readline writes to its own C-level stream and bypasses $stdout entirely.
15
+ # - key binding: Readline has parse_and_bind, Reline has add_default_key_binding.
16
+ # - what a completion candidate may contain: see #matches in each adapter.
17
+ module LineEditor
18
+ # Shift-Tab: jump to line start, kill the line, type /auto, submit.
19
+ # Reline binds real bytes; Readline's inputrc parser wants the escapes
20
+ # un-interpreted, so it gets the backslash form verbatim.
21
+ SHIFT_TAB = "\e[Z".freeze
22
+ AUTO_MACRO = "\C-a\C-k/auto\C-m".freeze
23
+ INPUTRC_BIND = '"\e[Z": "\C-a\C-k/auto\C-m"'.freeze
24
+
25
+ def self.resolve(preference = nil, output: nil)
26
+ preference = (preference || :auto).to_sym
27
+ editor =
28
+ case preference
29
+ when :readline then readline_adapter
30
+ when :reline then reline_adapter || readline_adapter
31
+ else reline_adapter || readline_adapter
32
+ end
33
+ editor.output = output if output && editor.respond_to?(:output=)
34
+ editor
35
+ end
36
+
37
+ def self.reline_adapter
38
+ require 'reline'
39
+ Reline.respond_to?(:autocompletion=) ? Reline_.new : nil
40
+ rescue LoadError
41
+ nil
42
+ end
43
+
44
+ def self.readline_adapter
45
+ require 'readline'
46
+ Readline_.new
47
+ end
48
+
49
+ class Base
50
+ def name; self.class.name.split('::').last.chomp('_').downcase; end
51
+
52
+ # Candidates come from a proc so the list stays live — skills and agents can
53
+ # be created mid-session. The proc returns [slug, label] pairs, where the
54
+ # label says what kind of thing the slug is ("command", "skill", "agent").
55
+ def complete_with(&candidates); @candidates = candidates; self; end
56
+
57
+ def matches(target)
58
+ matching(target).map(&:first)
59
+ end
60
+
61
+ private
62
+
63
+ def matching(target)
64
+ return [] unless target.to_s.start_with?('/')
65
+ entries = @candidates ? @candidates.call : []
66
+ entries.select { |slug, _| slug.start_with?(target) }
67
+ end
68
+ end
69
+
70
+ class Reline_ < Base
71
+ def initialize
72
+ Reline.autocompletion = true
73
+ Reline.completion_append_character = ' '
74
+ Reline.completion_proc = ->(target) { matches(target) }
75
+ end
76
+
77
+ def output=(io); Reline.output = io; end
78
+
79
+ def prompt(text, color)
80
+ "#{color}#{text}\e[0m"
81
+ end
82
+
83
+ def readline(prompt)
84
+ Reline.readline(prompt, false)
85
+ end
86
+
87
+ def push_history(line)
88
+ Reline::HISTORY.push(line) unless line == Reline::HISTORY.to_a.last
89
+ end
90
+
91
+ def bind_auto_toggle
92
+ Reline.core.config.add_default_key_binding(SHIFT_TAB.bytes, AUTO_MACRO.bytes)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ # Reline's menu inserts whichever row you arrow onto, verbatim, so a
98
+ # candidate has to be exactly the text that belongs in the buffer. No labels.
99
+ end
100
+
101
+ class Readline_ < Base
102
+ def initialize
103
+ Readline.completion_append_character = ' '
104
+ Readline.completion_proc = ->(target) { matches(target) }
105
+ end
106
+
107
+ def prompt(text, color)
108
+ "\001#{color}\002#{text}\001\e[0m\002"
109
+ end
110
+
111
+ def readline(prompt)
112
+ Readline.readline(prompt, false)
113
+ end
114
+
115
+ def push_history(line)
116
+ Readline::HISTORY.push(line) unless line == Readline::HISTORY.to_a.last
117
+ end
118
+
119
+ def bind_auto_toggle
120
+ return unless Readline.respond_to?(:parse_and_bind)
121
+ Readline.parse_and_bind(INPUTRC_BIND)
122
+ end
123
+
124
+ # Readline only ever inserts the common prefix of the candidates it is given,
125
+ # and displays the rest for the eye alone — so when there is more than one
126
+ # match we can append a kind label without it ever reaching the buffer. That
127
+ # is what makes a built-in command distinguishable from a skill or an agent
128
+ # in the Tab list. The labels sit past the point where the slugs diverge, so
129
+ # they can't lengthen the common prefix either.
130
+ #
131
+ # A lone match is different: there the common prefix IS the whole candidate,
132
+ # so it must be the bare slug, and Readline appends its trailing space.
133
+ def matches(target)
134
+ found = matching(target)
135
+ return found.map(&:first) if found.size <= 1
136
+
137
+ width = found.map { |slug, _| slug.length }.max + 2
138
+ found.map { |slug, label| "#{slug.ljust(width)}#{label}" }
139
+ end
140
+ end
141
+ end
142
+ end
@@ -55,6 +55,8 @@ module RailsConsoleAi
55
55
  }
56
56
  temp = config.resolved_temperature
57
57
  body[:temperature] = temp unless temp.nil?
58
+ effort = config.resolved_effort
59
+ body[:output_config] = { effort: effort } if effort
58
60
  if system_prompt
59
61
  body[:system] = [
60
62
  { 'type' => 'text', 'text' => system_prompt, 'cache_control' => cache_control }
@@ -74,7 +76,7 @@ module RailsConsoleAi
74
76
  usage = data['usage'] || {}
75
77
 
76
78
  tool_calls = extract_tool_calls(data)
77
- stop = data['stop_reason'] == 'tool_use' ? :tool_use : :end_turn
79
+ stop = normalize_stop_reason(data['stop_reason'], %w[tool_use], %w[max_tokens])
78
80
 
79
81
  ChatResult.new(
80
82
  text: extract_text(data),
@@ -5,6 +5,7 @@ module RailsConsoleAi
5
5
  module Providers
6
6
  class Base
7
7
  attr_reader :config
8
+ attr_accessor :routing_session_id
8
9
 
9
10
  def initialize(config = RailsConsoleAi.configuration)
10
11
  @config = config
@@ -28,6 +29,20 @@ module RailsConsoleAi
28
29
 
29
30
  private
30
31
 
32
+ # Every provider reports "I stopped because I ran out of room" under its own
33
+ # name, and the obvious mapping — anything that isn't a tool call is a
34
+ # finished turn — is wrong in a way that costs the whole turn: a response cut
35
+ # off mid-tool-call arrives with its arguments half-written, the engine sees
36
+ # :end_turn, discards the partial call and prints whatever preamble the model
37
+ # managed first. That reads as the model deciding to answer in one sentence.
38
+ # Naming truncation lets the engine say so and let the model recover.
39
+ def normalize_stop_reason(raw, tool_values, truncation_values)
40
+ raw = raw.to_s
41
+ return :tool_use if tool_values.include?(raw)
42
+ return :max_tokens if truncation_values.include?(raw)
43
+ :end_turn
44
+ end
45
+
31
46
  # Read and connect timeouts are separate budgets. Establishing the TCP/TLS
32
47
  # connection either happens in a couple of seconds or is not going to, while
33
48
  # generation legitimately takes minutes with adaptive thinking and a large
@@ -150,7 +165,7 @@ module RailsConsoleAi
150
165
  class ProviderError < StandardError; end
151
166
 
152
167
  ChatResult = Struct.new(:text, :input_tokens, :output_tokens, :tool_calls, :stop_reason,
153
- :cache_read_input_tokens, :cache_write_input_tokens, keyword_init: true) do
168
+ :cache_read_input_tokens, :cache_write_input_tokens, :cost, keyword_init: true) do
154
169
  def total_tokens
155
170
  (input_tokens || 0) + (output_tokens || 0)
156
171
  end
@@ -158,6 +173,12 @@ module RailsConsoleAi
158
173
  def tool_use?
159
174
  stop_reason == :tool_use && tool_calls && !tool_calls.empty?
160
175
  end
176
+
177
+ # The response hit the output cap mid-generation. Whatever it was writing —
178
+ # text, or the arguments of a tool call — is incomplete and cannot be used.
179
+ def truncated?
180
+ stop_reason == :max_tokens
181
+ end
161
182
  end
162
183
 
163
184
  def self.build(config = RailsConsoleAi.configuration)
@@ -168,6 +189,10 @@ module RailsConsoleAi
168
189
  when :openai
169
190
  require 'rails_console_ai/providers/openai'
170
191
  OpenAI.new(config)
192
+ when :openrouter
193
+ require 'rails_console_ai/providers/openai'
194
+ require 'rails_console_ai/providers/openrouter'
195
+ OpenRouter.new(config)
171
196
  when :local
172
197
  require 'rails_console_ai/providers/openai'
173
198
  require 'rails_console_ai/providers/local'