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
@@ -49,6 +49,9 @@ module RailsConsoleAi
49
49
  messages: mark_conversation_breakpoint(format_messages(messages)),
50
50
  inference_config: inference
51
51
  }
52
+ # Converse has no effort field; Anthropic request fields ride along here.
53
+ effort = config.resolved_effort
54
+ params[:additional_model_request_fields] = { output_config: { effort: effort } } if effort
52
55
  if system_prompt
53
56
  sys_blocks = [{ text: system_prompt }]
54
57
  sys_blocks << cache_point if cache_supported?
@@ -65,7 +68,7 @@ module RailsConsoleAi
65
68
  debug_bedrock_response(response)
66
69
 
67
70
  tool_calls = extract_tool_calls(response)
68
- stop = response.stop_reason == 'tool_use' ? :tool_use : :end_turn
71
+ stop = normalize_stop_reason(response.stop_reason, %w[tool_use], %w[max_tokens])
69
72
 
70
73
  usage = response.usage
71
74
  ChatResult.new(
@@ -78,7 +81,7 @@ module RailsConsoleAi
78
81
  stop_reason: stop
79
82
  )
80
83
  rescue aws_error_class => e
81
- raise ProviderError, "AWS Bedrock error: #{e.message}"
84
+ raise ProviderError, "AWS Bedrock error (#{e.class}): #{e.message}"
82
85
  end
83
86
 
84
87
  def client
@@ -3,38 +3,19 @@ module RailsConsoleAi
3
3
  class Local < OpenAI
4
4
  private
5
5
 
6
- def call_api(messages, system_prompt: nil, tools: nil)
7
- base_url = config.local_url
8
-
9
- headers = { 'Content-Type' => 'application/json' }
10
- api_key = config.local_api_key
11
- if api_key && api_key != 'no-key' && !api_key.empty?
12
- headers['Authorization'] = "Bearer #{api_key}"
13
- end
14
-
15
- conn = build_connection(base_url, headers)
16
-
17
- formatted = []
18
- formatted << { role: 'system', content: system_prompt } if system_prompt
19
- formatted.concat(format_messages(messages))
20
-
21
- body = {
22
- model: config.resolved_model,
23
- max_tokens: config.resolved_max_tokens,
24
- messages: formatted
25
- }
26
- temp = config.resolved_temperature
27
- body[:temperature] = temp unless temp.nil?
28
- body[:tools] = tools.to_openai_format if tools
6
+ def api_base
7
+ config.local_url
8
+ end
29
9
 
30
- estimated_input_tokens = estimate_tokens(formatted, system_prompt, tools)
10
+ def request_headers
11
+ key = config.local_api_key
12
+ return {} if key.nil? || key.empty? || key == 'no-key'
13
+ { 'Authorization' => "Bearer #{key}" }
14
+ end
31
15
 
32
- json_body = JSON.generate(body)
33
- debug_request("#{base_url}/v1/chat/completions", body)
34
- response = conn.post('/v1/chat/completions', json_body)
35
- debug_response(response.body)
36
- data = parse_response(response)
16
+ def build_result(data, body:, tools: nil)
37
17
  usage = data['usage'] || {}
18
+ estimated_input_tokens = estimate_tokens(body)
38
19
 
39
20
  prompt_tokens = usage['prompt_tokens']
40
21
  if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
@@ -50,9 +31,6 @@ module RailsConsoleAi
50
31
 
51
32
  tool_calls = extract_tool_calls(message)
52
33
 
53
- # Fallback: some local models (e.g. Ollama) emit tool calls as JSON
54
- # in the content field instead of using the structured tool_calls format.
55
- # Only match when the JSON "name" is a known tool name to avoid false positives.
56
34
  if tool_calls.empty? && tools
57
35
  tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
58
36
  text_calls = extract_tool_calls_from_text(message['content'], tool_names)
@@ -63,7 +41,7 @@ module RailsConsoleAi
63
41
  end
64
42
  end
65
43
 
66
- stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
44
+ stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
67
45
 
68
46
  ChatResult.new(
69
47
  text: message['content'] || '',
@@ -74,10 +52,12 @@ module RailsConsoleAi
74
52
  )
75
53
  end
76
54
 
77
- def estimate_tokens(messages, system_prompt, tools)
78
- chars = system_prompt.to_s.length
79
- messages.each { |m| chars += m[:content].to_s.length + (m[:tool_calls].to_s.length) }
80
- chars += tools.to_openai_format.to_s.length if tools
55
+ def estimate_tokens(body)
56
+ chars = 0
57
+ (body[:messages] || []).each do |m|
58
+ chars += m[:content].to_s.length + (m[:tool_calls].to_s.length)
59
+ end
60
+ chars += body[:tools].to_s.length if body[:tools]
81
61
  chars / 4
82
62
  end
83
63
 
@@ -40,12 +40,35 @@ module RailsConsoleAi
40
40
  private
41
41
 
42
42
  def call_api(messages, system_prompt: nil, tools: nil)
43
- conn = build_connection(API_URL, {
44
- 'Authorization' => "Bearer #{config.resolved_api_key}"
45
- })
43
+ conn = build_connection(api_base, request_headers)
44
+ body = build_body(messages, system_prompt: system_prompt, tools: tools)
45
+ debug_request("#{api_base}#{endpoint_path}", body)
46
+ response = with_retries { conn.post(endpoint_path, JSON.generate(body)) }
47
+ debug_response(response.body)
48
+ build_result(parse_response(response), body: body, tools: tools)
49
+ end
50
+
51
+ def api_base
52
+ API_URL
53
+ end
54
+
55
+ def endpoint_path
56
+ '/v1/chat/completions'
57
+ end
46
58
 
59
+ def request_headers
60
+ { 'Authorization' => "Bearer #{config.resolved_api_key}" }
61
+ end
62
+
63
+ # Overridable: providers that support explicit cache breakpoints emit
64
+ # multipart content here instead of a bare string.
65
+ def system_message(system_prompt)
66
+ { role: 'system', content: system_prompt }
67
+ end
68
+
69
+ def build_body(messages, system_prompt:, tools:)
47
70
  formatted = []
48
- formatted << { role: 'system', content: system_prompt } if system_prompt
71
+ formatted << system_message(system_prompt) if system_prompt
49
72
  formatted.concat(format_messages(messages))
50
73
 
51
74
  body = {
@@ -56,12 +79,10 @@ module RailsConsoleAi
56
79
  temp = config.resolved_temperature
57
80
  body[:temperature] = temp unless temp.nil?
58
81
  body[:tools] = tools.to_openai_format if tools
82
+ body
83
+ end
59
84
 
60
- json_body = JSON.generate(body)
61
- debug_request("#{API_URL}/v1/chat/completions", body)
62
- response = with_retries { conn.post('/v1/chat/completions', json_body) }
63
- debug_response(response.body)
64
- data = parse_response(response)
85
+ def build_result(data, body:, tools: nil)
65
86
  usage = data['usage'] || {}
66
87
 
67
88
  choice = (data['choices'] || []).first || {}
@@ -69,7 +90,7 @@ module RailsConsoleAi
69
90
  finish_reason = choice['finish_reason']
70
91
 
71
92
  tool_calls = extract_tool_calls(message)
72
- stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
93
+ stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
73
94
 
74
95
  ChatResult.new(
75
96
  text: message['content'] || '',
@@ -0,0 +1,108 @@
1
+ module RailsConsoleAi
2
+ module Providers
3
+ class OpenRouter < OpenAI
4
+ DEFAULT_URL = 'https://openrouter.ai'.freeze
5
+ ANTHROPIC_MODEL = /anthropic\/|claude/i
6
+
7
+ private
8
+
9
+ def api_base
10
+ config.openrouter_url || DEFAULT_URL
11
+ end
12
+
13
+ def endpoint_path
14
+ '/api/v1/chat/completions'
15
+ end
16
+
17
+ def request_headers
18
+ h = { 'Authorization' => "Bearer #{config.resolved_api_key}" }
19
+ h['HTTP-Referer'] = config.openrouter_site_url if config.openrouter_site_url
20
+ h['X-Title'] = config.openrouter_app_name if config.openrouter_app_name
21
+ h
22
+ end
23
+
24
+ def build_body(messages, system_prompt:, tools:)
25
+ body = super
26
+ # Root-level cache_control is OpenRouter's automatic mode: it places a
27
+ # breakpoint on the last cacheable block and moves it forward as the
28
+ # conversation grows, which is what a tool loop needs — otherwise every
29
+ # round re-bills the whole accumulated history at full input price.
30
+ body[:cache_control] = cache_control if cache_supported?
31
+ body[:session_id] = routing_session_id if routing_session_id
32
+ # OpenRouter only returns usage.cost — the real dollars every cost
33
+ # readout prefers over an estimate — when the request asks for it.
34
+ body[:usage] = { include: true }
35
+ # OpenRouter maps `verbosity` to Anthropic's output_config.effort; its
36
+ # `reasoning.effort` sets a thinking-token budget instead.
37
+ effort = config.resolved_effort
38
+ body[:verbosity] = effort if effort
39
+ body
40
+ end
41
+
42
+ # The static system prefix gets its own explicit breakpoint so it has a
43
+ # guaranteed read point no matter what happens later in `messages`; the
44
+ # automatic breakpoint above then covers the growing tail. OpenRouter
45
+ # expresses Anthropic breakpoints as OpenAI-style multipart content.
46
+ def system_message(system_prompt)
47
+ return super unless cache_supported?
48
+
49
+ { role: 'system',
50
+ content: [{ type: 'text', text: system_prompt, cache_control: cache_control }] }
51
+ end
52
+
53
+ # Same TTL on every breakpoint: entries with the longer TTL must precede
54
+ # shorter ones, and an explicit marker whose TTL differs from the root-level
55
+ # field's is rejected outright.
56
+ def cache_control
57
+ ttl = config.respond_to?(:resolved_cache_ttl) ? config.resolved_cache_ttl : nil
58
+ ttl ? { type: 'ephemeral', ttl: ttl } : { type: 'ephemeral' }
59
+ end
60
+
61
+ def build_result(data, body:, tools: nil)
62
+ raise_inline_error!(data)
63
+ result = super
64
+ usage = data['usage'] || {}
65
+ details = usage['prompt_tokens_details'] || {}
66
+
67
+ cache_read = details['cached_tokens'].to_i
68
+ cache_write = details['cache_write_tokens'].to_i
69
+ result.cache_read_input_tokens = cache_read
70
+ result.cache_write_input_tokens = cache_write
71
+ result.cost = usage['cost']
72
+
73
+ # OpenAI-shaped `prompt_tokens` is the WHOLE prompt, cached tokens
74
+ # included; Anthropic's `input_tokens` is the uncached remainder, and the
75
+ # engine is built on the Anthropic contract — it reconstructs total prompt
76
+ # volume as input + cache_read + cache_write for the runaway-loop budget
77
+ # breakers. Left as sent, a cached round counts twice and those breakers
78
+ # fire at half the volume they are set to.
79
+ if result.input_tokens
80
+ result.input_tokens = [result.input_tokens - cache_read - cache_write, 0].max
81
+ end
82
+
83
+ # Some OpenRouter upstreams return tool calls with a finish_reason of
84
+ # "stop"; trust the calls over the label. Not when the response was cut
85
+ # off at the output cap, though — those arguments are half-written, and
86
+ # promoting them to :tool_use hands the engine a truncated call to run.
87
+ if result.tool_calls&.any? && !result.truncated?
88
+ result.stop_reason = :tool_use
89
+ end
90
+
91
+ result
92
+ end
93
+
94
+ def cache_supported?
95
+ config.resolved_model.to_s.match?(ANTHROPIC_MODEL)
96
+ end
97
+
98
+ def raise_inline_error!(data)
99
+ err = data['error'] || (data['choices'] || []).first&.dig('error')
100
+ return unless err
101
+
102
+ msg = err.is_a?(Hash) ? (err['message'] || err.to_s) : err.to_s
103
+ code = err.is_a?(Hash) ? err['code'] : 'unknown'
104
+ raise ProviderError, "OpenRouter error (#{code}): #{msg}"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -453,7 +453,7 @@ module RailsConsoleAi
453
453
  Thread.current.report_on_exception = false
454
454
  Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
455
455
  begin
456
- channel.display_status("_session: #{channel_id}/#{thread_ts}_")
456
+ channel.display_status("_#{session_reference(engine, text, channel_id, thread_ts)}_")
457
457
  if restored
458
458
  puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
459
459
  channel.display_status("_(session restored — continuing from previous conversation)_")
@@ -468,6 +468,32 @@ module RailsConsoleAi
468
468
  end
469
469
  end
470
470
 
471
+ # A link to the session's page, so whoever is in the thread can find the
472
+ # transcript later. Falls back to the channel/thread pair the logs are
473
+ # tagged with when there is no row to link to, or no host to link with.
474
+ def session_reference(engine, text, channel_id, thread_ts)
475
+ session_id = engine.ensure_session_record(text)
476
+ unless session_id
477
+ return session_reference_fallback(channel_id, thread_ts, "no session row (session_logging off, or the sessions table not found)")
478
+ end
479
+
480
+ url = RailsConsoleAi.session_url(session_id)
481
+ unless url.start_with?('http')
482
+ return session_reference_fallback(channel_id, thread_ts, "no host for #{url} (set config.base_url)")
483
+ end
484
+
485
+ "Session ##{session_id}: <#{url}|view transcript>"
486
+ rescue => e
487
+ session_reference_fallback(channel_id, thread_ts, "#{e.class}: #{e.message}")
488
+ end
489
+
490
+ # Said on stdout, where the bot's own log goes, so a missing link is explained.
491
+ # The session thread already prefixes the line with the channel and thread.
492
+ def session_reference_fallback(channel_id, thread_ts, reason)
493
+ puts "no session link: #{reason}"
494
+ "Session: #{channel_id}/#{thread_ts}"
495
+ end
496
+
471
497
  def restore_from_db(engine, thread_ts)
472
498
  require 'rails_console_ai/session_logger'
473
499
  saved = SessionLogger.find_by_slack_thread(thread_ts)
@@ -652,14 +678,11 @@ module RailsConsoleAi
652
678
  when 'model'
653
679
  summary = bang_model(engine)
654
680
  post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
655
- when 'think'
656
- model = engine.upgrade_to_thinking_model
657
- post_message(channel: channel_id, thread_ts: thread_ts,
658
- text: "Switched to thinking model: `#{model}`")
659
- when 'unthink'
660
- model = engine.downgrade_from_thinking_model
681
+ when 'think', 'unthink', 'cheap'
682
+ tier = { 'think' => :thinking, 'unthink' => :default, 'cheap' => :cheap }[cmd]
683
+ engine.switch_model_tier(tier)
661
684
  post_message(channel: channel_id, thread_ts: thread_ts,
662
- text: "Switched to default model: `#{model}`")
685
+ text: "Switched to #{tier} model: `#{engine.effective_model_label}`")
663
686
  when 'context'
664
687
  summary = bang_context(engine)
665
688
  post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
@@ -678,7 +701,7 @@ module RailsConsoleAi
678
701
  end
679
702
  end
680
703
  else
681
- commands = %w[name usage cost model compact think unthink context retry].map { |c| "`!#{c}`" }
704
+ commands = %w[name usage cost model compact think unthink cheap context retry].map { |c| "`!#{c}`" }
682
705
  post_message(channel: channel_id, thread_ts: thread_ts,
683
706
  text: "Unknown command `!#{cmd}`. Available: #{commands.join(', ')}")
684
707
  end
@@ -705,13 +728,18 @@ module RailsConsoleAi
705
728
  def bang_model(engine = nil)
706
729
  config = RailsConsoleAi.configuration
707
730
  model = engine ? engine.effective_model : config.resolved_model
708
- thinking = config.resolved_thinking_model
709
731
  pricing = Configuration.pricing_for(model)
732
+ with_effort = lambda do |m, e|
733
+ e = Configuration.effort_for(m, e)
734
+ e ? "`#{m}` (#{e} effort)" : "`#{m}`"
735
+ end
710
736
 
711
737
  lines = ["*Model info:*"]
712
738
  lines << " Provider: `#{config.provider}`"
713
- lines << " Model: `#{model}`"
714
- lines << " Thinking model: `#{thinking}`"
739
+ lines << (engine ? " Model: `#{engine.effective_model_label}` [#{engine.model_tier}]" : " Model: `#{model}`")
740
+ lines << " Default model: #{with_effort.(config.resolved_model, config.effort)}"
741
+ lines << " Thinking model: #{with_effort.(config.resolved_thinking_model, config.thinking_effort)}"
742
+ lines << " Cheap model: `#{config.resolved_cheap_model}`"
715
743
  lines << " Max tokens: #{config.resolved_max_tokens}"
716
744
  if pricing
717
745
  lines << " Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out"
@@ -737,6 +765,7 @@ module RailsConsoleAi
737
765
 
738
766
  lines = ["*Cost estimate:*"]
739
767
  total_cost = 0.0
768
+ has_reported_cost = false
740
769
 
741
770
  token_usage.each do |model, usage|
742
771
  pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
@@ -744,18 +773,23 @@ module RailsConsoleAi
744
773
  input_str = "in: #{usage[:input]}"
745
774
  output_str = "out: #{usage[:output]}"
746
775
 
747
- if pricing
748
- cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
776
+ reported_cost = usage[:cost]
777
+ if reported_cost && reported_cost > 0
778
+ has_reported_cost = true
779
+ total_cost += reported_cost
780
+ cache_read = usage[:cache_read] || 0
781
+ cache_write = usage[:cache_write] || 0
782
+ cache_str = ""
783
+ cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
784
+ lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}"
785
+ elsif pricing
786
+ cost = Configuration.estimate_cost(model,
787
+ input: usage[:input], output: usage[:output],
788
+ cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
789
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
790
+ total_cost += cost if cost
749
791
  cache_read = usage[:cache_read] || 0
750
792
  cache_write = usage[:cache_write] || 0
751
- if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
752
- # input_tokens excludes cached tokens — bill each bucket at its own
753
- # rate rather than discounting cache_read out of input (see
754
- # ConversationEngine#display_cost_summary).
755
- cost += cache_read * pricing[:cache_read]
756
- cost += cache_write * pricing[:cache_write]
757
- end
758
- total_cost += cost
759
793
  cache_str = ""
760
794
  cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
761
795
  lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}"
@@ -764,7 +798,8 @@ module RailsConsoleAi
764
798
  end
765
799
  end
766
800
 
767
- lines << "*Total: ~$#{'%.2f' % total_cost}*"
801
+ label = has_reported_cost ? "Total:" : "Total: ~"
802
+ lines << "*#{label}$#{'%.2f' % total_cost}*"
768
803
  lines.join("\n")
769
804
  end
770
805
 
@@ -0,0 +1,150 @@
1
+ require 'rails_console_ai/skill_loader'
2
+ require 'rails_console_ai/agent_loader'
3
+
4
+ module RailsConsoleAi
5
+ # The union of everything a user can invoke by typing "/" in interactive mode:
6
+ # the fixed REPL commands, plus every activatable skill and agent, each exposed
7
+ # under a slug derived from its display name ("Restart user trial" -> /restart-user-trial).
8
+ #
9
+ # Backs three surfaces that must agree with each other: the "/" listing, the
10
+ # completion candidates offered by the line editor, and the dispatcher that
11
+ # decides what a typed slash command actually does.
12
+ class SlashCommands
13
+ Command = Struct.new(:slug, :name, :description, :kind, :record, keyword_init: true) do
14
+ def builtin?; kind == :builtin; end
15
+ def skill?; kind == :skill; end
16
+ def agent?; kind == :agent; end
17
+ end
18
+
19
+ KIND_LABELS = { builtin: 'command', skill: 'skill', agent: 'agent' }.freeze
20
+
21
+ # Slug => one-line help. Order is the order they render in the listing.
22
+ # Descriptions that depend on live state (auto-execute on/off, safe mode)
23
+ # are filled in by the channel at render time; these are the static fallbacks.
24
+ BUILTINS = {
25
+ 'auto' => 'Toggle auto-execute',
26
+ 'danger' => 'Toggle safe mode',
27
+ 'safe' => 'Show safety guard status',
28
+ 'model' => 'Show provider, model, and pricing info',
29
+ 'think' => 'Switch to thinking model',
30
+ 'unthink' => 'Switch back to default model',
31
+ 'cheap' => 'Switch to the cheaper, faster model',
32
+ 'compact' => 'Summarize conversation to reduce context',
33
+ 'usage' => 'Show session token totals',
34
+ 'cost' => 'Show cost estimate by model',
35
+ 'name' => 'Name this session for easy resume',
36
+ 'context' => 'Show conversation history sent to the LLM',
37
+ 'system' => 'Show the system prompt',
38
+ 'expand' => 'Show full omitted output',
39
+ 'debug' => 'Toggle debug summaries',
40
+ 'retry' => 'Re-execute the last code block'
41
+ }.freeze
42
+
43
+ # "Restart user trial" -> restart-user-trial.
44
+ #
45
+ # Close to SkillLoader#skill_key / AgentLoader#agent_key, but kinder to names
46
+ # that aren't plain prose: those two drop "/" and case boundaries outright,
47
+ # which turns "Approve/Reject ChangeApprovals" into the unreadable
48
+ # "approvereject-changeapprovals". Here every boundary a human would read as
49
+ # a word break becomes a dash. The slug is only ever a handle for typing and
50
+ # dispatch — file lookup still goes through the loaders — so the two are free
51
+ # to differ.
52
+ def self.slugify(name)
53
+ name.to_s.strip
54
+ .gsub(%r{[/_\s]+}, '-') # separators
55
+ .gsub(/([a-z0-9])([A-Z])/, '\1-\2') # fooBar -> foo-Bar
56
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2') # HTTPToken -> HTTP-Token
57
+ .downcase
58
+ .gsub(/[^a-z0-9-]/, '')
59
+ .gsub(/-+/, '-')
60
+ .sub(/\A-/, '').sub(/-\z/, '')
61
+ end
62
+
63
+ def initialize(skill_loader: nil, agent_loader: nil)
64
+ @skill_loader = skill_loader
65
+ @agent_loader = agent_loader
66
+ end
67
+
68
+ # Loading skills and agents touches the DB and the filesystem, and completion
69
+ # runs on every keystroke — so the list is built once and reused until something
70
+ # that could have changed it (a save_skill/save_agent tool call) finishes.
71
+ def refresh!
72
+ @commands = nil
73
+ self
74
+ end
75
+
76
+ def commands
77
+ @commands ||= build
78
+ end
79
+
80
+ def skills
81
+ commands.select(&:skill?)
82
+ end
83
+
84
+ def agents
85
+ commands.select(&:agent?)
86
+ end
87
+
88
+ # Completion candidates, already "/"-prefixed.
89
+ def candidates
90
+ commands.map { |c| "/#{c.slug}" }
91
+ end
92
+
93
+ # [slug, kind label] pairs for the line editor. The label is what lets a Tab
94
+ # list say which entries are built-in commands and which are skills or agents.
95
+ def completion_entries
96
+ commands.map { |c| ["/#{c.slug}", KIND_LABELS[c.kind]] }
97
+ end
98
+
99
+ def find(slug)
100
+ slug = slug.to_s.sub(%r{\A/}, '').downcase
101
+ commands.find { |c| c.slug == slug }
102
+ end
103
+
104
+ private
105
+
106
+ def build
107
+ builtins = BUILTINS.map do |slug, desc|
108
+ Command.new(slug: slug, name: slug, description: desc, kind: :builtin, record: nil)
109
+ end
110
+
111
+ taken = builtins.map(&:slug)
112
+
113
+ skills = safe(:skills) do
114
+ skill_loader.load_activatable_skills.filter_map do |s|
115
+ slug = self.class.slugify(s['name'])
116
+ next if slug.empty? || taken.include?(slug)
117
+ taken << slug
118
+ Command.new(slug: slug, name: s['name'], description: s['description'], kind: :skill, record: s)
119
+ end
120
+ end
121
+
122
+ agents = safe(:agents) do
123
+ agent_loader.load_activatable_agents.filter_map do |a|
124
+ slug = self.class.slugify(a['name'])
125
+ next if slug.empty? || taken.include?(slug)
126
+ taken << slug
127
+ Command.new(slug: slug, name: a['name'], description: a['description'], kind: :agent, record: a)
128
+ end
129
+ end
130
+
131
+ builtins + skills.sort_by(&:slug) + agents.sort_by(&:slug)
132
+ end
133
+
134
+ # A broken skill file or an unmigrated database must not take down the prompt.
135
+ def safe(what)
136
+ yield
137
+ rescue => e
138
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load #{what} for slash commands: #{e.message}")
139
+ []
140
+ end
141
+
142
+ def skill_loader
143
+ @skill_loader ||= SkillLoader.new
144
+ end
145
+
146
+ def agent_loader
147
+ @agent_loader ||= AgentLoader.new
148
+ end
149
+ end
150
+ end
@@ -10,7 +10,7 @@ module RailsConsoleAi
10
10
  LARGE_OUTPUT_THRESHOLD = 10_000
11
11
  LARGE_OUTPUT_PREVIEW_CHARS = 8_000
12
12
 
13
- attr_reader :input_tokens, :output_tokens, :model_used
13
+ attr_reader :input_tokens, :output_tokens, :model_used, :cost
14
14
 
15
15
  def initialize(task:, agent_config:, binding_context:, parent_channel:, executor:,
16
16
  output_payload: nil, output_local_name: :output)
@@ -23,6 +23,7 @@ module RailsConsoleAi
23
23
  @output_local_name = output_local_name
24
24
  @input_tokens = 0
25
25
  @output_tokens = 0
26
+ @cost = 0
26
27
  @model_used = nil
27
28
  end
28
29
 
@@ -83,8 +84,18 @@ module RailsConsoleAi
83
84
  end
84
85
  @input_tokens += result.input_tokens || 0
85
86
  @output_tokens += result.output_tokens || 0
87
+ @cost += result.cost || 0
86
88
 
87
89
  break if channel.cancelled?
90
+
91
+ # Cut off at the output cap: the tool call it was writing is incomplete
92
+ # and gets discarded, so the sub-agent would otherwise return whatever
93
+ # half-sentence preceded it as if that were its finding.
94
+ if result.truncated?
95
+ channel.display_warning(" Sub-agent response hit the output-token cap — its result is incomplete.")
96
+ break
97
+ end
98
+
88
99
  break unless result.tool_use?
89
100
 
90
101
  # Display the LLM's reasoning text before executing its tool calls
@@ -152,6 +163,7 @@ module RailsConsoleAi
152
163
  result = provider.chat_with_tools(messages, tools: tools, system_prompt: system_prompt)
153
164
  @input_tokens += result.input_tokens || 0
154
165
  @output_tokens += result.output_tokens || 0
166
+ @cost += result.cost || 0
155
167
  end
156
168
 
157
169
  text = result&.text.to_s
@@ -181,7 +193,7 @@ module RailsConsoleAi
181
193
  config = RailsConsoleAi.configuration
182
194
  model_override = @agent_config['model'] || config.sub_agent_model
183
195
 
184
- if model_override
196
+ p = if model_override
185
197
  config_dup = config.dup
186
198
  config_dup.model = model_override
187
199
  @model_used = model_override
@@ -190,6 +202,8 @@ module RailsConsoleAi
190
202
  @model_used = config.resolved_model
191
203
  Providers.build(config)
192
204
  end
205
+ p.routing_session_id = SecureRandom.hex(8) if p.respond_to?(:routing_session_id=)
206
+ p
193
207
  end
194
208
 
195
209
  def build_system_prompt
@@ -197,6 +211,7 @@ module RailsConsoleAi
197
211
  parts << base_instructions unless @agent_config['skip_base_instructions']
198
212
  parts << guide_context
199
213
  parts << pinned_memory_context
214
+ parts << memory_context if memory_recall_available?
200
215
  parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
201
216
  parts.compact.join("\n\n")
202
217
  end
@@ -212,6 +227,9 @@ module RailsConsoleAi
212
227
  - Use tools to look up schema/model details rather than guessing.
213
228
  - Prefer ActiveRecord query interface over raw SQL.
214
229
  - Use describe_model to understand models before querying them.
230
+ - Check the Memories section below BEFORE investigating. If a memory looks relevant,
231
+ call recall_memory (by name) or recall_memories (by keyword) to read it, and apply it.
232
+ Memories record how this app actually works; do not rediscover what one already says.
215
233
  - NEVER fabricate URLs, IDs, or data. Always look things up using model methods.
216
234
  - When you find methods on a model (e.g. via .methods.grep), USE them rather than
217
235
  constructing values manually.
@@ -230,6 +248,21 @@ module RailsConsoleAi
230
248
  nil
231
249
  end
232
250
 
251
+ # Same memory index the main agent sees, so the sub-agent knows what it can
252
+ # recall instead of finding out only if it happens to call recall_memories.
253
+ def memory_context
254
+ require 'rails_console_ai/context_builder'
255
+ ContextBuilder.new(RailsConsoleAi.configuration).memory_context
256
+ end
257
+
258
+ # An agent definition can restrict tools; the output-explorer does, to
259
+ # execute_code alone. Listing memories it cannot recall would only invite
260
+ # calls to a tool it does not have.
261
+ def memory_recall_available?
262
+ allowed = @agent_config['tools']
263
+ allowed.nil? || Array(allowed).include?('recall_memories')
264
+ end
265
+
233
266
  def pinned_memory_context
234
267
  channel_mode = @parent_channel.respond_to?(:mode) ? @parent_channel.mode : nil
235
268
  # For sub-agents spawned from Slack, use the Slack channel's pinned memories.