rails_console_ai 0.35.0 → 0.36.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.
@@ -737,6 +737,7 @@ module RailsConsoleAi
737
737
 
738
738
  lines = ["*Cost estimate:*"]
739
739
  total_cost = 0.0
740
+ has_reported_cost = false
740
741
 
741
742
  token_usage.each do |model, usage|
742
743
  pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
@@ -744,18 +745,23 @@ module RailsConsoleAi
744
745
  input_str = "in: #{usage[:input]}"
745
746
  output_str = "out: #{usage[:output]}"
746
747
 
747
- if pricing
748
- cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
748
+ reported_cost = usage[:cost]
749
+ if reported_cost && reported_cost > 0
750
+ has_reported_cost = true
751
+ total_cost += reported_cost
752
+ cache_read = usage[:cache_read] || 0
753
+ cache_write = usage[:cache_write] || 0
754
+ cache_str = ""
755
+ cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
756
+ lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}"
757
+ elsif pricing
758
+ cost = Configuration.estimate_cost(model,
759
+ input: usage[:input], output: usage[:output],
760
+ cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
761
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
762
+ total_cost += cost if cost
749
763
  cache_read = usage[:cache_read] || 0
750
764
  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
765
  cache_str = ""
760
766
  cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
761
767
  lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}"
@@ -764,7 +770,8 @@ module RailsConsoleAi
764
770
  end
765
771
  end
766
772
 
767
- lines << "*Total: ~$#{'%.2f' % total_cost}*"
773
+ label = has_reported_cost ? "Total:" : "Total: ~"
774
+ lines << "*#{label}$#{'%.2f' % total_cost}*"
768
775
  lines.join("\n")
769
776
  end
770
777
 
@@ -0,0 +1,149 @@
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
+ 'compact' => 'Summarize conversation to reduce context',
32
+ 'usage' => 'Show session token totals',
33
+ 'cost' => 'Show cost estimate by model',
34
+ 'name' => 'Name this session for easy resume',
35
+ 'context' => 'Show conversation history sent to the LLM',
36
+ 'system' => 'Show the system prompt',
37
+ 'expand' => 'Show full omitted output',
38
+ 'debug' => 'Toggle debug summaries',
39
+ 'retry' => 'Re-execute the last code block'
40
+ }.freeze
41
+
42
+ # "Restart user trial" -> restart-user-trial.
43
+ #
44
+ # Close to SkillLoader#skill_key / AgentLoader#agent_key, but kinder to names
45
+ # that aren't plain prose: those two drop "/" and case boundaries outright,
46
+ # which turns "Approve/Reject ChangeApprovals" into the unreadable
47
+ # "approvereject-changeapprovals". Here every boundary a human would read as
48
+ # a word break becomes a dash. The slug is only ever a handle for typing and
49
+ # dispatch — file lookup still goes through the loaders — so the two are free
50
+ # to differ.
51
+ def self.slugify(name)
52
+ name.to_s.strip
53
+ .gsub(%r{[/_\s]+}, '-') # separators
54
+ .gsub(/([a-z0-9])([A-Z])/, '\1-\2') # fooBar -> foo-Bar
55
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2') # HTTPToken -> HTTP-Token
56
+ .downcase
57
+ .gsub(/[^a-z0-9-]/, '')
58
+ .gsub(/-+/, '-')
59
+ .sub(/\A-/, '').sub(/-\z/, '')
60
+ end
61
+
62
+ def initialize(skill_loader: nil, agent_loader: nil)
63
+ @skill_loader = skill_loader
64
+ @agent_loader = agent_loader
65
+ end
66
+
67
+ # Loading skills and agents touches the DB and the filesystem, and completion
68
+ # runs on every keystroke — so the list is built once and reused until something
69
+ # that could have changed it (a save_skill/save_agent tool call) finishes.
70
+ def refresh!
71
+ @commands = nil
72
+ self
73
+ end
74
+
75
+ def commands
76
+ @commands ||= build
77
+ end
78
+
79
+ def skills
80
+ commands.select(&:skill?)
81
+ end
82
+
83
+ def agents
84
+ commands.select(&:agent?)
85
+ end
86
+
87
+ # Completion candidates, already "/"-prefixed.
88
+ def candidates
89
+ commands.map { |c| "/#{c.slug}" }
90
+ end
91
+
92
+ # [slug, kind label] pairs for the line editor. The label is what lets a Tab
93
+ # list say which entries are built-in commands and which are skills or agents.
94
+ def completion_entries
95
+ commands.map { |c| ["/#{c.slug}", KIND_LABELS[c.kind]] }
96
+ end
97
+
98
+ def find(slug)
99
+ slug = slug.to_s.sub(%r{\A/}, '').downcase
100
+ commands.find { |c| c.slug == slug }
101
+ end
102
+
103
+ private
104
+
105
+ def build
106
+ builtins = BUILTINS.map do |slug, desc|
107
+ Command.new(slug: slug, name: slug, description: desc, kind: :builtin, record: nil)
108
+ end
109
+
110
+ taken = builtins.map(&:slug)
111
+
112
+ skills = safe(:skills) do
113
+ skill_loader.load_activatable_skills.filter_map do |s|
114
+ slug = self.class.slugify(s['name'])
115
+ next if slug.empty? || taken.include?(slug)
116
+ taken << slug
117
+ Command.new(slug: slug, name: s['name'], description: s['description'], kind: :skill, record: s)
118
+ end
119
+ end
120
+
121
+ agents = safe(:agents) do
122
+ agent_loader.load_activatable_agents.filter_map do |a|
123
+ slug = self.class.slugify(a['name'])
124
+ next if slug.empty? || taken.include?(slug)
125
+ taken << slug
126
+ Command.new(slug: slug, name: a['name'], description: a['description'], kind: :agent, record: a)
127
+ end
128
+ end
129
+
130
+ builtins + skills.sort_by(&:slug) + agents.sort_by(&:slug)
131
+ end
132
+
133
+ # A broken skill file or an unmigrated database must not take down the prompt.
134
+ def safe(what)
135
+ yield
136
+ rescue => e
137
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load #{what} for slash commands: #{e.message}")
138
+ []
139
+ end
140
+
141
+ def skill_loader
142
+ @skill_loader ||= SkillLoader.new
143
+ end
144
+
145
+ def agent_loader
146
+ @agent_loader ||= AgentLoader.new
147
+ end
148
+ end
149
+ 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,6 +84,7 @@ 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?
88
90
  break unless result.tool_use?
@@ -152,6 +154,7 @@ module RailsConsoleAi
152
154
  result = provider.chat_with_tools(messages, tools: tools, system_prompt: system_prompt)
153
155
  @input_tokens += result.input_tokens || 0
154
156
  @output_tokens += result.output_tokens || 0
157
+ @cost += result.cost || 0
155
158
  end
156
159
 
157
160
  text = result&.text.to_s
@@ -181,7 +184,7 @@ module RailsConsoleAi
181
184
  config = RailsConsoleAi.configuration
182
185
  model_override = @agent_config['model'] || config.sub_agent_model
183
186
 
184
- if model_override
187
+ p = if model_override
185
188
  config_dup = config.dup
186
189
  config_dup.model = model_override
187
190
  @model_used = model_override
@@ -190,6 +193,8 @@ module RailsConsoleAi
190
193
  @model_used = config.resolved_model
191
194
  Providers.build(config)
192
195
  end
196
+ p.routing_session_id = SecureRandom.hex(8) if p.respond_to?(:routing_session_id=)
197
+ p
193
198
  end
194
199
 
195
200
  def build_system_prompt
@@ -381,7 +381,7 @@ module RailsConsoleAi
381
381
  # the live database. Scoped via thread-local (explore_output runs synchronously),
382
382
  # so the parent session's guard state is untouched.
383
383
  result = RailsConsoleAi.configuration.safety_guards.with_database_blocked { sub.run }
384
- @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used }
384
+ @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost }
385
385
  "Exploration result (#{sub.input_tokens + sub.output_tokens} tokens used, #{payload.length} chars explored):\n#{result}"
386
386
  end
387
387
 
@@ -418,7 +418,7 @@ module RailsConsoleAi
418
418
  executor: @executor
419
419
  )
420
420
  result = sub.run
421
- @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used }
421
+ @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost }
422
422
  "Sub-agent result (#{sub.input_tokens + sub.output_tokens} tokens used):\n#{result}"
423
423
  end
424
424
 
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.35.0'.freeze
2
+ VERSION = '0.36.0'.freeze
3
3
  end
@@ -125,6 +125,7 @@ module RailsConsoleAi
125
125
  lines << " Model: #{c.resolved_model}"
126
126
  lines << " API key: #{masked_key}"
127
127
  lines << " Local URL: #{c.local_url}" if c.provider == :local
128
+ lines << " OpenRouter URL: #{c.openrouter_url}" if c.openrouter_url
128
129
  lines << " Max tokens: #{c.max_tokens || '(auto)'}"
129
130
  lines << " Temperature: #{c.temperature}"
130
131
  lines << " Timeout: #{c.timeout}s"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.35.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -166,18 +166,21 @@ files:
166
166
  - lib/rails_console_ai/conversation_engine.rb
167
167
  - lib/rails_console_ai/engine.rb
168
168
  - lib/rails_console_ai/executor.rb
169
+ - lib/rails_console_ai/line_editor.rb
169
170
  - lib/rails_console_ai/prefixed_io.rb
170
171
  - lib/rails_console_ai/providers/anthropic.rb
171
172
  - lib/rails_console_ai/providers/base.rb
172
173
  - lib/rails_console_ai/providers/bedrock.rb
173
174
  - lib/rails_console_ai/providers/local.rb
174
175
  - lib/rails_console_ai/providers/openai.rb
176
+ - lib/rails_console_ai/providers/openrouter.rb
175
177
  - lib/rails_console_ai/railtie.rb
176
178
  - lib/rails_console_ai/repl.rb
177
179
  - lib/rails_console_ai/safety_guards.rb
178
180
  - lib/rails_console_ai/session_logger.rb
179
181
  - lib/rails_console_ai/skill_loader.rb
180
182
  - lib/rails_console_ai/slack_bot.rb
183
+ - lib/rails_console_ai/slash_commands.rb
181
184
  - lib/rails_console_ai/storage/base.rb
182
185
  - lib/rails_console_ai/storage/database_storage.rb
183
186
  - lib/rails_console_ai/storage/file_storage.rb