rails_console_ai 0.36.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.
@@ -32,6 +32,10 @@ module RailsConsoleAi
32
32
  # OpenRouter only returns usage.cost — the real dollars every cost
33
33
  # readout prefers over an estimate — when the request asks for it.
34
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
35
39
  body
36
40
  end
37
41
 
@@ -76,7 +80,11 @@ module RailsConsoleAi
76
80
  result.input_tokens = [result.input_tokens - cache_read - cache_write, 0].max
77
81
  end
78
82
 
79
- if result.tool_calls&.any?
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?
80
88
  result.stop_reason = :tool_use
81
89
  end
82
90
 
@@ -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
681
+ when 'think', 'unthink', 'cheap'
682
+ tier = { 'think' => :thinking, 'unthink' => :default, 'cheap' => :cheap }[cmd]
683
+ engine.switch_model_tier(tier)
657
684
  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
661
- 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"
@@ -28,6 +28,7 @@ module RailsConsoleAi
28
28
  'model' => 'Show provider, model, and pricing info',
29
29
  'think' => 'Switch to thinking model',
30
30
  'unthink' => 'Switch back to default model',
31
+ 'cheap' => 'Switch to the cheaper, faster model',
31
32
  'compact' => 'Summarize conversation to reduce context',
32
33
  'usage' => 'Show session token totals',
33
34
  'cost' => 'Show cost estimate by model',
@@ -87,6 +87,15 @@ module RailsConsoleAi
87
87
  @cost += result.cost || 0
88
88
 
89
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
+
90
99
  break unless result.tool_use?
91
100
 
92
101
  # Display the LLM's reasoning text before executing its tool calls
@@ -202,6 +211,7 @@ module RailsConsoleAi
202
211
  parts << base_instructions unless @agent_config['skip_base_instructions']
203
212
  parts << guide_context
204
213
  parts << pinned_memory_context
214
+ parts << memory_context if memory_recall_available?
205
215
  parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
206
216
  parts.compact.join("\n\n")
207
217
  end
@@ -217,6 +227,9 @@ module RailsConsoleAi
217
227
  - Use tools to look up schema/model details rather than guessing.
218
228
  - Prefer ActiveRecord query interface over raw SQL.
219
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.
220
233
  - NEVER fabricate URLs, IDs, or data. Always look things up using model methods.
221
234
  - When you find methods on a model (e.g. via .methods.grep), USE them rather than
222
235
  constructing values manually.
@@ -235,6 +248,21 @@ module RailsConsoleAi
235
248
  nil
236
249
  end
237
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
+
238
266
  def pinned_memory_context
239
267
  channel_mode = @parent_channel.respond_to?(:mode) ? @parent_channel.mode : nil
240
268
  # For sub-agents spawned from Slack, use the Slack channel's pinned memories.
@@ -6,7 +6,7 @@ module RailsConsoleAi
6
6
  attr_reader :definitions, :last_sub_agent_usage
7
7
 
8
8
  # Tools that should never be cached (side effects or user interaction)
9
- NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill save_agent delete_agent delegate_task explore_output].freeze
9
+ NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill save_agent delete_agent delegate_task explore_output save_artifact list_artifacts read_artifact delete_artifact].freeze
10
10
 
11
11
  def initialize(executor: nil, mode: :default, channel: nil, allowed_tools: nil)
12
12
  @executor = executor
@@ -258,6 +258,236 @@ module RailsConsoleAi
258
258
  register_execute_plan
259
259
  register_delegate_task
260
260
  end
261
+
262
+ register_artifact_tools
263
+ end
264
+ end
265
+
266
+ ARTIFACT_TYPES = %w[text/html text/markdown text/csv application/json text/plain].freeze
267
+
268
+ # How much of an artifact read_artifact returns inline before handing back
269
+ # an output_id instead. A full report would otherwise swamp the context.
270
+ ARTIFACT_READ_LIMIT = 12_000
271
+
272
+ def register_artifact_tools
273
+ register(
274
+ name: 'save_artifact',
275
+ description: 'Save a generated file — an HTML report, a CSV extract, a JSON dump — so the user can open it in a browser ' \
276
+ 'instead of reading it in the console. Use this whenever the user asks for a summary, report, export or page, ' \
277
+ 'or whenever the output is a document rather than an answer. ' \
278
+ 'Returns a URL to view it. Prefer this over printing hundreds of lines of HTML or CSV into the transcript. ' \
279
+ 'New artifacts are stamped with today\'s date automatically — pass a plain title like "Q3 churn summary", ' \
280
+ 'not a dated one. Saving under a name that already exists REPLACES its content and bumps its revision; ' \
281
+ 'to revise an artifact from an earlier day pass its exact handle (e.g. "20260911_Q3 churn summary") as the name, ' \
282
+ 'and call read_artifact first if you need to keep most of what is already there. ' \
283
+ 'HTML is rendered in a sandboxed frame: inline CSS works, but scripts never run, so do not rely on JavaScript ' \
284
+ 'or on loading anything over the network — inline all styles and embed any data directly. ' \
285
+ 'IMPORTANT: content must be the complete literal text, never a placeholder to fill in later. ' \
286
+ 'If the content already exists as a value in the console — a file you read, a string you built — do NOT ' \
287
+ 'retype it here. Your response has an output-token limit and a large file will be cut off mid-argument, ' \
288
+ 'discarding the call. Use execute_code instead and save it directly from Ruby: ' \
289
+ 'RailsConsoleAi.save_artifact(name: "Q3 churn summary", content: the_string). Same naming and revision ' \
290
+ 'rules, and it returns the URL.',
291
+ parameters: {
292
+ 'type' => 'object',
293
+ 'properties' => {
294
+ 'name' => { 'type' => 'string', 'description' => 'Short title, e.g. "Q3 churn summary". Reusing an existing name updates that artifact.' },
295
+ 'content_type' => {
296
+ 'type' => 'string',
297
+ 'enum' => ARTIFACT_TYPES,
298
+ 'description' => 'MIME type of the content'
299
+ },
300
+ 'content' => { 'type' => 'string', 'description' => 'The full, literal file content — not a placeholder. Replaces the previous content entirely. For content that already exists as a console value, use RailsConsoleAi.save_artifact from execute_code instead.' },
301
+ 'description' => { 'type' => 'string', 'description' => 'Optional one-line note about what this is and how it was built' }
302
+ },
303
+ 'required' => ['name', 'content_type', 'content']
304
+ },
305
+ handler: ->(args) {
306
+ save_artifact(args['name'], args['content_type'], args['content'], args['description'])
307
+ }
308
+ )
309
+
310
+ register(
311
+ name: 'list_artifacts',
312
+ description: 'List saved artifacts, newest first, with the handle each one is addressed by. ' \
313
+ 'Call this when the user refers to an artifact from an earlier session ("update the churn report") ' \
314
+ 'and you do not already know its handle.',
315
+ parameters: {
316
+ 'type' => 'object',
317
+ 'properties' => {
318
+ 'search' => { 'type' => 'string', 'description' => 'Optional filter over name and description' }
319
+ },
320
+ 'required' => []
321
+ },
322
+ handler: ->(args) { list_artifacts(args['search']) }
323
+ )
324
+
325
+ register(
326
+ name: 'read_artifact',
327
+ description: 'Read back the content of a saved artifact so you can revise it. ' \
328
+ 'Accepts the handle, the name, or the token from its URL.',
329
+ parameters: {
330
+ 'type' => 'object',
331
+ 'properties' => {
332
+ 'handle' => { 'type' => 'string', 'description' => 'Handle, name, or token, e.g. "q3-churn-summary"' }
333
+ },
334
+ 'required' => ['handle']
335
+ },
336
+ handler: ->(args) { read_artifact(args['handle']) }
337
+ )
338
+
339
+ register(
340
+ name: 'delete_artifact',
341
+ description: 'Delete a saved artifact. Only when the user asks for it to be removed.',
342
+ parameters: {
343
+ 'type' => 'object',
344
+ 'properties' => {
345
+ 'handle' => { 'type' => 'string', 'description' => 'Handle, name, or token' }
346
+ },
347
+ 'required' => ['handle']
348
+ },
349
+ handler: ->(args) { delete_artifact(args['handle']) }
350
+ )
351
+ end
352
+
353
+ # Referencing the constant is what makes Rails autoload it. `defined?` does
354
+ # not: under autoloading the constant is absent until something asks for it
355
+ # by name, so a `defined?` guard reports "no model" in a perfectly healthy
356
+ # app and the tool never runs. NameError is the real "no engine here" case.
357
+ def artifact_model
358
+ RailsConsoleAi::Artifact
359
+ rescue NameError
360
+ nil
361
+ end
362
+
363
+ # Nil when artifacts can't be used at all; otherwise the model class.
364
+ # Returns the explanation as a String so each tool can hand it straight back.
365
+ def artifact_store
366
+ model = artifact_model
367
+ return [nil, 'artifacts need the Rails engine loaded.'] unless model
368
+ unless model.available?
369
+ return [nil, 'the rails_console_ai_artifacts table does not exist. ' \
370
+ 'Ask the user to run `ai_db_setup` in the Rails console, then try again.']
371
+ end
372
+ [model, nil]
373
+ end
374
+
375
+ # Addressed by slug so the link itself says what it points at. Absolute
376
+ # when the app's base URL is known: this string is read in a terminal,
377
+ # pasted into Slack and opened on a phone, none of which resolve a path.
378
+ def artifact_url(artifact)
379
+ "#{RailsConsoleAi.base_url}#{RailsConsoleAi.engine_mount_path}/artifacts/#{artifact.slug}"
380
+ end
381
+
382
+ # The write itself lives on the model, shared with RailsConsoleAi.save_artifact
383
+ # so a console-side save and a tool-side save land identically.
384
+ def save_artifact(name, content_type, content, description = nil)
385
+ model, problem = artifact_store
386
+ return "FAILED to save artifact: #{problem}" if problem
387
+
388
+ existing = model.find_by(slug: model.slug_for(model.stamped_name(name)))
389
+ verb = existing ? 'updated' : 'saved'
390
+
391
+ artifact = model.store(name: name, content_type: content_type,
392
+ content: content, description: description)
393
+
394
+ if artifact.errors.any? || artifact.new_record?
395
+ return "FAILED to save artifact: #{artifact.errors.full_messages.join('; ')}"
396
+ end
397
+
398
+ url = artifact_url(artifact)
399
+ revision_note = existing ? " revision #{artifact.revision}," : ''
400
+ if @channel
401
+ @channel.display_status(" Artifact #{verb}: #{artifact.name} (#{artifact.human_size}) #{url}")
402
+ end
403
+ "Artifact #{verb}: \"#{artifact.name}\" (handle: #{artifact.slug},#{revision_note} " \
404
+ "#{artifact.content_type}, #{artifact.human_size}). " \
405
+ "The user can view it at #{url} — give them that URL. " \
406
+ "Refer to it later as #{artifact.slug}."
407
+ rescue ::ActiveRecord::ActiveRecordError => e
408
+ "FAILED to save artifact (#{e.class}: #{e.message})."
409
+ end
410
+
411
+ def list_artifacts(search = nil)
412
+ model, problem = artifact_store
413
+ return "FAILED to list artifacts: #{problem}" if problem
414
+
415
+ scope = model.newest_first
416
+ search = search.to_s.strip
417
+ unless search.empty?
418
+ needle = "%#{search.downcase}%"
419
+ scope = scope.where("LOWER(name) LIKE :q OR LOWER(COALESCE(description, '')) LIKE :q", q: needle)
420
+ end
421
+
422
+ rows = scope.limit(50).to_a
423
+ if rows.empty?
424
+ return search.empty? ? 'No artifacts saved yet.' : "No artifacts match #{search.inspect}."
425
+ end
426
+
427
+ lines = rows.map { |a|
428
+ bits = ["#{a.slug} — \"#{a.name}\"", a.content_type, a.human_size]
429
+ bits << "rev #{a.revision}" if a.revision.to_i > 1
430
+ bits << a.updated_at.strftime('%Y-%m-%d')
431
+ line = bits.join(', ')
432
+ line += "\n #{a.description}" if a.description.present?
433
+ "- #{line}"
434
+ }
435
+ "#{rows.length} artifact#{rows.length == 1 ? '' : 's'} (address one by its handle):\n#{lines.join("\n")}"
436
+ rescue ::ActiveRecord::ActiveRecordError => e
437
+ "FAILED to list artifacts (#{e.class}: #{e.message})."
438
+ end
439
+
440
+ def read_artifact(handle)
441
+ model, problem = artifact_store
442
+ return "FAILED to read artifact: #{problem}" if problem
443
+
444
+ artifact = model.find_by_handle(handle)
445
+ return artifact_not_found(model, handle) unless artifact
446
+
447
+ header = "Artifact \"#{artifact.name}\" (handle: #{artifact.slug}, #{artifact.content_type}, " \
448
+ "#{artifact.human_size}, revision #{artifact.revision}):\n"
449
+
450
+ body = artifact.content.to_s
451
+ return header + body if body.length <= ARTIFACT_READ_LIMIT
452
+
453
+ # Same escape hatch large tool results use: keep the head, park the rest
454
+ # where explore_output can query it rather than flooding the context.
455
+ output_id = @executor && @executor.store_output(body)
456
+ preview = body[0, ARTIFACT_READ_LIMIT]
457
+ tail = if output_id
458
+ "\n\n[Truncated at #{ARTIFACT_READ_LIMIT} of #{body.length} chars — " \
459
+ "use explore_output with output_id=#{output_id} to query the rest, " \
460
+ "or recall_output to expand it in place]"
461
+ else
462
+ "\n\n[Truncated at #{ARTIFACT_READ_LIMIT} of #{body.length} chars]"
463
+ end
464
+ header + preview + tail
465
+ rescue ::ActiveRecord::ActiveRecordError => e
466
+ "FAILED to read artifact (#{e.class}: #{e.message})."
467
+ end
468
+
469
+ def delete_artifact(handle)
470
+ model, problem = artifact_store
471
+ return "FAILED to delete artifact: #{problem}" if problem
472
+
473
+ artifact = model.find_by_handle(handle)
474
+ return artifact_not_found(model, handle) unless artifact
475
+
476
+ name = artifact.name
477
+ slug = artifact.slug
478
+ artifact.destroy
479
+ @channel.display_status(" Deleted artifact: #{name}") if @channel
480
+ "Artifact deleted: \"#{name}\" (#{slug})."
481
+ rescue ::ActiveRecord::ActiveRecordError => e
482
+ "FAILED to delete artifact (#{e.class}: #{e.message})."
483
+ end
484
+
485
+ def artifact_not_found(model, handle)
486
+ available = model.newest_first.limit(20).pluck(:slug)
487
+ if available.empty?
488
+ "No artifact found for #{handle.inspect}. There are no saved artifacts."
489
+ else
490
+ "No artifact found for #{handle.inspect}. Available handles: #{available.join(', ')}"
261
491
  end
262
492
  end
263
493
 
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.36.0'.freeze
2
+ VERSION = '0.38.0'.freeze
3
3
  end