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,6 +1,6 @@
1
1
  module RailsConsoleAi
2
2
  class Configuration
3
- PROVIDERS = %i[anthropic openai local bedrock].freeze
3
+ PROVIDERS = %i[anthropic openai openrouter local bedrock].freeze
4
4
 
5
5
  # Per-family model attributes, matched by substring so one entry covers every
6
6
  # ID variant of a family: bare Anthropic IDs (claude-sonnet-5), dated
@@ -8,19 +8,23 @@ module RailsConsoleAi
8
8
  # (us.anthropic.claude-sonnet-5, global.anthropic.claude-opus-4-6-v1).
9
9
  #
10
10
  # input/output are $ per MTok (converted to per-token in .pricing_for).
11
- # Cache pricing is derived: read = 0.1x input, write = 1.25x input.
11
+ # Cache pricing is derived: read = 0.1x input, write = 1.25x input, unless a
12
+ # family sets cache_read ($ per MTok) explicitly (opus-5-5 reads at 0.05x).
12
13
  # temperature: false marks families that reject the `temperature` parameter
13
- # (removed on opus-4-7+, sonnet-5, and fable-5).
14
+ # (removed on opus-4-7+, sonnet-5+, and fable-5).
15
+ # effort: false marks families that reject `output_config.effort` (haiku-4-5).
14
16
  # max_tokens is the OUTPUT cap we request; context is the total window.
15
17
  MODEL_FAMILIES = {
16
18
  'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
19
+ 'claude-opus-5-5' => { input: 4.0, output: 20.0, cache_read: 0.20, max_tokens: 16_000, context: 1_000_000, temperature: false },
17
20
  'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
18
21
  'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
19
22
  'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
20
23
  'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
24
+ 'claude-sonnet-5-5' => { input: 2.0, output: 10.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
21
25
  'claude-sonnet-5' => { input: 2.0, output: 10.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
22
26
  'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
23
- 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, context: 200_000, temperature: true },
27
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, context: 200_000, temperature: true, effort: false },
24
28
  }.freeze
25
29
 
26
30
  # Assumed context window for models with no family entry — local models and
@@ -28,18 +32,28 @@ module RailsConsoleAi
28
32
  # little early, over-guessing means no warning before the request is rejected.
29
33
  DEFAULT_CONTEXT_WINDOW = 200_000
30
34
 
35
+ EFFORT_LEVELS = %w[low medium high xhigh max].freeze
36
+
31
37
  # Family keys sorted longest-first so a more specific family always wins
32
- # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
33
- # before 'claude-sonnet-5').
38
+ # when keys overlap (e.g. 'claude-sonnet-5-5' matches before 'claude-sonnet-5').
34
39
  MODEL_FAMILY_KEYS = MODEL_FAMILIES.keys.sort_by { |k| -k.length }.freeze
35
40
 
36
41
  # Returns the family attributes for a model ID, or nil for unknown models.
37
42
  def self.model_family(model_id)
38
43
  return nil unless model_id
39
- key = MODEL_FAMILY_KEYS.find { |k| model_id.include?(k) }
44
+ normalized = model_id.gsub(/(?<=\d)\.(?=\d)/, '-')
45
+ key = MODEL_FAMILY_KEYS.find { |k| normalized.include?(k) }
40
46
  key && MODEL_FAMILIES[key]
41
47
  end
42
48
 
49
+ # The effort a model would be sent, or nil when it takes none: unknown models
50
+ # (OpenAI, local) and families marked effort: false.
51
+ def self.effort_for(model_id, effort)
52
+ return nil if effort.nil? || effort.to_s.empty?
53
+ family = model_family(model_id)
54
+ family && family[:effort] != false ? effort.to_s : nil
55
+ end
56
+
43
57
  # Per-token pricing for a model ID, matched by family. Returns
44
58
  # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
45
59
  # Cache reads bill at 0.1x the base input rate; cache writes at 1.25x for the
@@ -51,11 +65,27 @@ module RailsConsoleAi
51
65
  {
52
66
  input: input,
53
67
  output: family[:output] / 1_000_000,
54
- cache_read: input * 0.1,
68
+ cache_read: family[:cache_read] ? family[:cache_read] / 1_000_000 : input * 0.1,
55
69
  cache_write: input * (cache_ttl.to_s == '1h' ? 2.0 : 1.25),
56
70
  }
57
71
  end
58
72
 
73
+ # The four usage buckets each bill at their own rate. The API's `input_tokens`
74
+ # is the UNCACHED remainder — cached tokens are reported separately and are not
75
+ # part of it (total prompt size is input + cache_read + cache_write), so
76
+ # discounting cache_read out of input double-counts and can drive a cost
77
+ # negative. Every estimated cost readout goes through here. Providers that
78
+ # report real dollars (OpenRouter) are preferred over this estimate.
79
+ def self.estimate_cost(model, input:, output:, cache_read: 0, cache_write: 0, cache_ttl: nil)
80
+ pricing = pricing_for(model, cache_ttl: cache_ttl)
81
+ return nil unless pricing
82
+
83
+ ((input || 0) * pricing[:input]) +
84
+ ((output || 0) * pricing[:output]) +
85
+ ((cache_read || 0) * pricing[:cache_read]) +
86
+ ((cache_write || 0) * pricing[:cache_write])
87
+ end
88
+
59
89
  # Total context window for a model ID, matched by family.
60
90
  def self.context_window_for(model_id)
61
91
  family = model_family(model_id)
@@ -81,7 +111,8 @@ module RailsConsoleAi
81
111
  }
82
112
  ].freeze
83
113
 
84
- attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
114
+ attr_accessor :provider, :api_key, :model, :thinking_model, :cheap_model, :max_tokens,
115
+ :effort, :thinking_effort,
85
116
  :auto_execute, :temperature,
86
117
  :timeout, :open_timeout, :max_retries, :debug, :max_tool_rounds,
87
118
  :error_hints,
@@ -93,19 +124,29 @@ module RailsConsoleAi
93
124
  :authenticate,
94
125
  :slack_bot_token, :slack_app_token, :slack_channel_ids, :slack_allowed_usernames,
95
126
  :local_url, :local_model, :local_api_key,
127
+ :openrouter_url, :openrouter_app_name, :openrouter_site_url,
96
128
  :bedrock_region,
97
129
  :code_search_paths,
98
130
  :channels,
99
131
  :bypass_guards_for_methods,
100
132
  :user_extra_info,
101
133
  :sub_agent_max_rounds,
102
- :sub_agent_model
134
+ :sub_agent_model,
135
+ :line_editor,
136
+ :base_url,
137
+ :artifact_date_prefix
103
138
 
104
139
  def initialize
105
140
  @provider = :anthropic
106
141
  @api_key = nil
107
142
  @model = nil
108
143
  @thinking_model = nil
144
+ @cheap_model = nil
145
+ # The default and thinking tiers run the same model by default and differ
146
+ # only in how hard it thinks. Sent as output_config.effort, and only to
147
+ # model families that accept it. The cheap tier sends no effort.
148
+ @effort = 'medium'
149
+ @thinking_effort = 'high'
109
150
  @max_tokens = nil
110
151
  @auto_execute = false
111
152
  @temperature = 0.2
@@ -147,6 +188,9 @@ module RailsConsoleAi
147
188
  @local_url = 'http://localhost:11434'
148
189
  @local_model = 'qwen2.5:7b'
149
190
  @local_api_key = nil
191
+ @openrouter_url = nil
192
+ @openrouter_app_name = nil
193
+ @openrouter_site_url = nil
150
194
  @bedrock_region = nil
151
195
  @code_search_paths = %w[app]
152
196
  @channels = {}
@@ -154,6 +198,18 @@ module RailsConsoleAi
154
198
  @user_extra_info = {}
155
199
  @sub_agent_max_rounds = 15
156
200
  @sub_agent_model = nil
201
+ # Interactive line editor: :auto prefers Reline (live "/" completion menu)
202
+ # and falls back to Readline. Force one with :reline or :readline.
203
+ @line_editor = :auto
204
+ # Public base URL of the host app, e.g. "https://secure.apptoto.com". Used
205
+ # to build links the gem hands out — an artifact URL is read in a console,
206
+ # pasted into Slack or opened on a phone, where a bare path is useless.
207
+ # Falls back to the app's default_url_options, then to a relative path.
208
+ @base_url = nil
209
+ # Stamp new artifact names with the day they were made ("20260911_Churn
210
+ # report"), so a list of them reads chronologically. Updates keep the name
211
+ # and date they were created with.
212
+ @artifact_date_prefix = true
157
213
  end
158
214
 
159
215
  def resolve_user_extra_info(username)
@@ -240,6 +296,8 @@ module RailsConsoleAi
240
296
  ENV['ANTHROPIC_API_KEY']
241
297
  when :openai
242
298
  ENV['OPENAI_API_KEY']
299
+ when :openrouter
300
+ ENV['OPENROUTER_API_KEY']
243
301
  when :local
244
302
  @local_api_key || 'no-key'
245
303
  when :bedrock
@@ -250,11 +308,32 @@ module RailsConsoleAi
250
308
  def resolved_model
251
309
  return @model if @model && !@model.empty?
252
310
 
311
+ case @provider
312
+ when :anthropic
313
+ 'claude-opus-5-5'
314
+ when :openai
315
+ 'gpt-5.3-codex'
316
+ when :openrouter
317
+ 'anthropic/claude-opus-5.5'
318
+ when :local
319
+ @local_model
320
+ when :bedrock
321
+ 'us.anthropic.claude-opus-5-5'
322
+ end
323
+ end
324
+
325
+ # The model /cheap switches to: faster and cheaper than the default, for
326
+ # routine lookups where the default's depth isn't needed.
327
+ def resolved_cheap_model
328
+ return @cheap_model if @cheap_model && !@cheap_model.empty?
329
+
253
330
  case @provider
254
331
  when :anthropic
255
332
  'claude-sonnet-5'
256
333
  when :openai
257
334
  'gpt-5.3-codex'
335
+ when :openrouter
336
+ 'anthropic/claude-sonnet-5'
258
337
  when :local
259
338
  @local_model
260
339
  when :bedrock
@@ -266,17 +345,24 @@ module RailsConsoleAi
266
345
  return @max_tokens if @max_tokens
267
346
 
268
347
  family = self.class.model_family(resolved_model)
269
- family ? family[:max_tokens] : 4096
348
+ return family[:max_tokens] if family
349
+
350
+ @provider == :openrouter ? 16_000 : 4096
270
351
  end
271
352
 
272
353
  # Returns nil for model families that reject the `temperature` parameter
273
- # (opus-4-7+, sonnet-5, fable-5) so providers omit the field from the request.
354
+ # (opus-4-7+, sonnet-5+, fable-5) so providers omit the field from the request.
274
355
  def resolved_temperature
275
356
  family = self.class.model_family(resolved_model)
276
357
  return nil if family && family[:temperature] == false
277
358
  @temperature
278
359
  end
279
360
 
361
+ # Returns the effort to send for the configured model, or nil to omit it.
362
+ def resolved_effort
363
+ self.class.effort_for(resolved_model, @effort)
364
+ end
365
+
280
366
  # Returns '1h' when the 1-hour prompt cache is requested, else nil (the
281
367
  # 5-minute default). Providers that offer only one cache duration ignore it.
282
368
  def resolved_cache_ttl
@@ -289,13 +375,15 @@ module RailsConsoleAi
289
375
 
290
376
  case @provider
291
377
  when :anthropic
292
- 'claude-opus-5'
378
+ 'claude-opus-5-5'
293
379
  when :openai
294
380
  'gpt-5.3-codex'
381
+ when :openrouter
382
+ 'anthropic/claude-opus-5.5'
295
383
  when :local
296
384
  @local_model
297
385
  when :bedrock
298
- 'us.anthropic.claude-opus-5'
386
+ 'us.anthropic.claude-opus-5-5'
299
387
  end
300
388
  end
301
389
 
@@ -303,11 +391,22 @@ module RailsConsoleAi
303
391
  @provider == :local ? [@timeout, 300].max : @timeout
304
392
  end
305
393
 
394
+ ENV_KEYS = {
395
+ anthropic: 'ANTHROPIC_API_KEY',
396
+ openai: 'OPENAI_API_KEY',
397
+ openrouter: 'OPENROUTER_API_KEY'
398
+ }.freeze
399
+
306
400
  def validate!
307
401
  unless PROVIDERS.include?(@provider)
308
402
  raise ConfigurationError, "Unknown provider: #{@provider}. Valid: #{PROVIDERS.join(', ')}"
309
403
  end
310
404
 
405
+ { effort: @effort, thinking_effort: @thinking_effort }.each do |name, value|
406
+ next if value.nil? || EFFORT_LEVELS.include?(value.to_s)
407
+ raise ConfigurationError, "Invalid #{name}: #{value.inspect}. Valid: #{EFFORT_LEVELS.join(', ')}"
408
+ end
409
+
311
410
  if @provider == :local
312
411
  raise ConfigurationError, "No local_url configured for :local provider." unless @local_url && !@local_url.empty?
313
412
  elsif @provider == :bedrock
@@ -319,7 +418,7 @@ module RailsConsoleAi
319
418
  end
320
419
  else
321
420
  unless resolved_api_key
322
- env_var = @provider == :anthropic ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'
421
+ env_var = ENV_KEYS[@provider] || 'API_KEY'
323
422
  raise ConfigurationError, "No API key. Set config.api_key or #{env_var} env var."
324
423
  end
325
424
  end
@@ -48,6 +48,26 @@ module RailsConsoleAi
48
48
  lines.join("\n")
49
49
  end
50
50
 
51
+ # The index of memory names the model can recall, with the instruction to
52
+ # look before answering. Public because SubAgent builds its own prompt and
53
+ # shares this section, so a sub-agent knows the same memories exist.
54
+ def memory_context
55
+ return nil unless @config.memories_enabled
56
+
57
+ require 'rails_console_ai/tools/memory_tools'
58
+ summaries = Tools::MemoryTools.new.memory_summaries
59
+ return nil if summaries.nil? || summaries.empty?
60
+
61
+ lines = ["## Memories"]
62
+ lines.concat(summaries)
63
+ lines << ""
64
+ lines << "Call recall_memory (by name) or recall_memories (by keyword) to get details before answering. Do NOT guess from the name alone."
65
+ lines.join("\n")
66
+ rescue => e
67
+ RailsConsoleAi.logger.debug("RailsConsoleAi: memory context failed: #{e.message}")
68
+ nil
69
+ end
70
+
51
71
  private
52
72
 
53
73
  def smart_system_instructions
@@ -102,6 +122,34 @@ module RailsConsoleAi
102
122
  - This keeps your conversation context small and efficient.
103
123
  - If a custom agent is available for the task (see Agents section), specify it by name.
104
124
 
125
+ You have artifact tools for output that is a document rather than an answer:
126
+ - When the user asks for a report, summary, export, table or page, build it with
127
+ save_artifact instead of printing it into the console. Reply with the URL it returns,
128
+ and tell them the handle so they can ask for it again later.
129
+ - New artifacts are named with today's date: "Q3 churn summary" is stored as
130
+ "20260911_Q3 churn summary" and addressed as 20260911-q3-churn-summary. Do not add the
131
+ date yourself — pass the plain title and it is stamped for you.
132
+ - Saving under a name that already exists replaces that artifact and bumps its revision.
133
+ To revise one from an earlier day, pass its exact handle as the name; passing the plain
134
+ title again makes a fresh artifact under today's date.
135
+ - When the user refers to an earlier artifact ("update the churn report", "add last month
136
+ to that table"), use list_artifacts to find its handle, read_artifact to get what is
137
+ already there, then save_artifact under the SAME name to replace it. Do not start a new
138
+ one unless they want a new one.
139
+ - delete_artifact removes one, only when asked.
140
+ - When the content already exists as a value in the console — a file you read from S3,
141
+ a string you built in code — do NOT retype it into save_artifact. Your response has an
142
+ output-token limit, and a large file will be cut off mid-argument and the call thrown
143
+ away. Save it from Ruby instead, inside execute_code:
144
+ html = S3.read_file("report.html")
145
+ RailsConsoleAi.save_artifact(name: "Mathnasium migration", content: html)
146
+ That is also the only way to store something verbatim: anything you retype is your
147
+ recollection of the bytes, not the bytes. Use save_artifact for content you are
148
+ composing yourself.
149
+ - HTML artifacts render in a sandboxed frame: inline CSS works, but scripts never run and
150
+ nothing loads over the network. Inline every style and embed the data directly.
151
+ - Use text/csv or application/json when the user wants data to take elsewhere.
152
+
105
153
  RULES:
106
154
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
107
155
  - For multi-step tasks, use execute_plan to break the work into small, clear steps.
@@ -195,23 +243,5 @@ module RailsConsoleAi
195
243
  RailsConsoleAi.logger.debug("RailsConsoleAi: pinned memory context failed: #{e.message}")
196
244
  nil
197
245
  end
198
-
199
- def memory_context
200
- return nil unless @config.memories_enabled
201
-
202
- require 'rails_console_ai/tools/memory_tools'
203
- summaries = Tools::MemoryTools.new.memory_summaries
204
- return nil if summaries.nil? || summaries.empty?
205
-
206
- lines = ["## Memories"]
207
- lines.concat(summaries)
208
- lines << ""
209
- lines << "Call recall_memory (by name) or recall_memories (by keyword) to get details before answering. Do NOT guess from the name alone."
210
- lines.join("\n")
211
- rescue => e
212
- RailsConsoleAi.logger.debug("RailsConsoleAi: memory context failed: #{e.message}")
213
- nil
214
- end
215
-
216
246
  end
217
247
  end