rails_console_ai 0.34.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +56 -2
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -4
- data/app/views/rails_console_ai/sessions/index.html.erb +1 -1
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -3
- data/lib/rails_console_ai/channel/console.rb +120 -15
- data/lib/rails_console_ai/configuration.rb +97 -19
- data/lib/rails_console_ai/conversation_engine.rb +241 -63
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +53 -9
- data/lib/rails_console_ai/providers/base.rb +74 -4
- data/lib/rails_console_ai/providers/bedrock.rb +47 -3
- data/lib/rails_console_ai/providers/local.rb +16 -36
- data/lib/rails_console_ai/providers/openai.rb +30 -9
- data/lib/rails_console_ai/providers/openrouter.rb +100 -0
- data/lib/rails_console_ai/session_logger.rb +4 -0
- data/lib/rails_console_ai/slack_bot.rb +19 -10
- data/lib/rails_console_ai/slash_commands.rb +149 -0
- data/lib/rails_console_ai/sub_agent.rb +15 -5
- data/lib/rails_console_ai/tools/registry.rb +2 -2
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +15 -0
- metadata +4 -1
|
@@ -737,23 +737,31 @@ 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
|
-
pricing = Configuration.pricing_for(model)
|
|
743
|
+
pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
743
744
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
744
745
|
input_str = "in: #{usage[:input]}"
|
|
745
746
|
output_str = "out: #{usage[:output]}"
|
|
746
747
|
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
cost -= cache_read * pricing[:input]
|
|
753
|
-
cost += cache_read * pricing[:cache_read]
|
|
754
|
-
cost += cache_write * (pricing[:cache_write] - pricing[:input])
|
|
755
|
-
end
|
|
756
|
-
total_cost += cost
|
|
757
765
|
cache_str = ""
|
|
758
766
|
cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
|
|
759
767
|
lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}"
|
|
@@ -762,7 +770,8 @@ module RailsConsoleAi
|
|
|
762
770
|
end
|
|
763
771
|
end
|
|
764
772
|
|
|
765
|
-
|
|
773
|
+
label = has_reported_cost ? "Total:" : "Total: ~"
|
|
774
|
+
lines << "*#{label}$#{'%.2f' % total_cost}*"
|
|
766
775
|
lines.join("\n")
|
|
767
776
|
end
|
|
768
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?
|
|
@@ -144,13 +146,19 @@ module RailsConsoleAi
|
|
|
144
146
|
end
|
|
145
147
|
|
|
146
148
|
if exhausted
|
|
147
|
-
messages << { role: :user, content: "Provide your best answer now based on what you've learned." }
|
|
148
|
-
|
|
149
|
+
messages << { role: :user, content: "Provide your best answer now based on what you've learned. Do not call any more tools." }
|
|
150
|
+
# Must be chat_with_tools, not chat: the transcript contains
|
|
151
|
+
# tool_use/tool_result blocks, and Bedrock/Anthropic reject those unless
|
|
152
|
+
# the request also defines tools. Any tool calls in the response are
|
|
153
|
+
# ignored — only the text is used.
|
|
154
|
+
result = provider.chat_with_tools(messages, tools: tools, system_prompt: system_prompt)
|
|
149
155
|
@input_tokens += result.input_tokens || 0
|
|
150
156
|
@output_tokens += result.output_tokens || 0
|
|
157
|
+
@cost += result.cost || 0
|
|
151
158
|
end
|
|
152
159
|
|
|
153
|
-
|
|
160
|
+
text = result&.text.to_s
|
|
161
|
+
text.strip.empty? ? '(sub-agent returned no result)' : text
|
|
154
162
|
end
|
|
155
163
|
|
|
156
164
|
def format_user_interruption(messages)
|
|
@@ -176,7 +184,7 @@ module RailsConsoleAi
|
|
|
176
184
|
config = RailsConsoleAi.configuration
|
|
177
185
|
model_override = @agent_config['model'] || config.sub_agent_model
|
|
178
186
|
|
|
179
|
-
if model_override
|
|
187
|
+
p = if model_override
|
|
180
188
|
config_dup = config.dup
|
|
181
189
|
config_dup.model = model_override
|
|
182
190
|
@model_used = model_override
|
|
@@ -185,6 +193,8 @@ module RailsConsoleAi
|
|
|
185
193
|
@model_used = config.resolved_model
|
|
186
194
|
Providers.build(config)
|
|
187
195
|
end
|
|
196
|
+
p.routing_session_id = SecureRandom.hex(8) if p.respond_to?(:routing_session_id=)
|
|
197
|
+
p
|
|
188
198
|
end
|
|
189
199
|
|
|
190
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
|
|
data/lib/rails_console_ai.rb
CHANGED
|
@@ -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"
|
|
@@ -154,6 +155,8 @@ module RailsConsoleAi
|
|
|
154
155
|
t.text :conversation, null: false
|
|
155
156
|
t.integer :input_tokens, default: 0
|
|
156
157
|
t.integer :output_tokens, default: 0
|
|
158
|
+
t.integer :cache_read_tokens, default: 0
|
|
159
|
+
t.integer :cache_write_tokens, default: 0
|
|
157
160
|
t.string :user_name, limit: 255
|
|
158
161
|
t.string :mode, limit: 20, null: false
|
|
159
162
|
t.text :code_executed
|
|
@@ -397,6 +400,18 @@ module RailsConsoleAi
|
|
|
397
400
|
migrations << 'options'
|
|
398
401
|
end
|
|
399
402
|
|
|
403
|
+
# Without these, a session row records only the UNCACHED remainder of its
|
|
404
|
+
# input and the admin cost column reads near-zero for every cached session.
|
|
405
|
+
unless conn.column_exists?(table, :cache_read_tokens)
|
|
406
|
+
conn.add_column(table, :cache_read_tokens, :integer, default: 0)
|
|
407
|
+
migrations << 'cache_read_tokens'
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
unless conn.column_exists?(table, :cache_write_tokens)
|
|
411
|
+
conn.add_column(table, :cache_write_tokens, :integer, default: 0)
|
|
412
|
+
migrations << 'cache_write_tokens'
|
|
413
|
+
end
|
|
414
|
+
|
|
400
415
|
unless conn.index_exists?(table, [:mode, :status], name: 'idx_rca_sessions_mode_status')
|
|
401
416
|
conn.add_index(table, [:mode, :status], name: 'idx_rca_sessions_mode_status')
|
|
402
417
|
migrations << 'idx_rca_sessions_mode_status'
|
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.
|
|
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
|