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
@@ -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
 
@@ -381,7 +611,7 @@ module RailsConsoleAi
381
611
  # the live database. Scoped via thread-local (explore_output runs synchronously),
382
612
  # so the parent session's guard state is untouched.
383
613
  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 }
614
+ @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost }
385
615
  "Exploration result (#{sub.input_tokens + sub.output_tokens} tokens used, #{payload.length} chars explored):\n#{result}"
386
616
  end
387
617
 
@@ -418,7 +648,7 @@ module RailsConsoleAi
418
648
  executor: @executor
419
649
  )
420
650
  result = sub.run
421
- @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used }
651
+ @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost }
422
652
  "Sub-agent result (#{sub.input_tokens + sub.output_tokens} tokens used):\n#{result}"
423
653
  end
424
654
 
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.35.0'.freeze
2
+ VERSION = '0.38.0'.freeze
3
3
  end
@@ -57,6 +57,146 @@ module RailsConsoleAi
57
57
  @current_user = name
58
58
  end
59
59
 
60
+ # Scheme + host for links the gem hands out, without a trailing slash.
61
+ # Empty string when nothing is configured and Rails has no url options, in
62
+ # which case callers fall back to a relative path.
63
+ def base_url
64
+ configured = configuration.base_url.to_s.strip
65
+ return normalize_base_url(configured) unless configured.empty?
66
+
67
+ from_rails = rails_default_url_base
68
+ from_rails ? normalize_base_url(from_rails) : ''
69
+ end
70
+
71
+ def normalize_base_url(value)
72
+ value = value.to_s.strip.chomp('/')
73
+ return '' if value.empty?
74
+ # Forgiving about a host given without a scheme — "secure.apptoto.com".
75
+ value = "https://#{value}" unless value =~ %r{\A[a-z][a-z0-9+.-]*://}i
76
+ value
77
+ end
78
+
79
+ # Most apps already declare their canonical host for mailers or url helpers.
80
+ def rails_default_url_base
81
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
82
+
83
+ opts = ::Rails.application.routes.default_url_options
84
+ opts = nil if opts.nil? || opts[:host].to_s.empty?
85
+
86
+ if opts.nil?
87
+ mailer = ::Rails.application.config.action_mailer.default_url_options rescue nil
88
+ opts = mailer if mailer && !mailer[:host].to_s.empty?
89
+ end
90
+ return nil unless opts && !opts[:host].to_s.empty?
91
+
92
+ protocol = opts[:protocol].to_s.sub(%r{://\z}, '')
93
+ protocol = 'https' if protocol.empty?
94
+ port = opts[:port]
95
+ host = opts[:host].to_s.chomp('/')
96
+ port_part = port && ![80, 443, '80', '443'].include?(port) ? ":#{port}" : ''
97
+ "#{protocol}://#{host}#{port_part}"
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ # Where the host app mounted the engine, so a tool can hand the user a URL
103
+ # that actually resolves. Discovered from the route table rather than assumed,
104
+ # since the mount point is the host's choice.
105
+ def engine_mount_path
106
+ return @engine_mount_path if defined?(@engine_mount_path) && @engine_mount_path
107
+
108
+ @engine_mount_path =
109
+ begin
110
+ found = nil
111
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
112
+ ::Rails.application.routes.routes.each do |route|
113
+ app = route.app
114
+ # Mounted engines sit behind one or more wrappers; unwrap a bounded
115
+ # number of times rather than trusting the shape of any one Rails version.
116
+ 5.times do
117
+ break unless app.respond_to?(:app) && !app.app.equal?(app)
118
+ app = app.app
119
+ end
120
+ next unless app == RailsConsoleAi::Engine
121
+
122
+ found = route.path.spec.to_s.sub(/\(\.:format\)\z/, '').chomp('/')
123
+ break
124
+ end
125
+ end
126
+ found.to_s.empty? ? '/rails_console_ai' : found
127
+ rescue StandardError
128
+ '/rails_console_ai'
129
+ end
130
+ end
131
+
132
+ # Where a logged session can be read back. Relative when no host is known.
133
+ def session_url(id)
134
+ "#{base_url}#{engine_mount_path}/sessions/#{id}"
135
+ end
136
+
137
+ # The session row the current turn belongs to, so tools that persist
138
+ # something (save_artifact) can point back at the conversation that produced
139
+ # it. Set by the engine as it logs each turn; nil outside a logged session.
140
+ def current_session_id
141
+ @current_session_id
142
+ end
143
+
144
+ def current_session_id=(id)
145
+ @current_session_id = id
146
+ end
147
+
148
+ # Save an artifact from Ruby, for content the console already holds.
149
+ #
150
+ # html = S3.read_file("report.html")
151
+ # RailsConsoleAi.save_artifact(name: "Mathnasium migration", content: html)
152
+ #
153
+ # This exists because the save_artifact TOOL can only carry content the model
154
+ # types out into its response, and a response has a hard output-token cap. A
155
+ # 40KB file does not fit under it: the call is cut off mid-argument, discarded,
156
+ # and retried identically forever. Going through Ruby, the bytes never enter
157
+ # the model's response at all — and what lands in the artifact is the string
158
+ # itself, byte for byte, not the model's recollection of it.
159
+ #
160
+ # Returns the URL. Saving under a name that already exists replaces that
161
+ # artifact and bumps its revision, exactly as the tool does.
162
+ def save_artifact(name:, content:, content_type: nil, description: nil)
163
+ model = artifact_model
164
+ raise 'Artifacts need the Rails engine loaded.' unless model
165
+ unless model.available?
166
+ raise "The rails_console_ai_artifacts table does not exist. Run `ai_db_setup` first."
167
+ end
168
+
169
+ content_type ||= model.infer_content_type(content)
170
+ # Called from inside execute_code, this INSERT would otherwise be stopped by
171
+ # the database_writes guard — which is the whole point of safe mode, except
172
+ # that the target is the gem's own artifacts table. The save_artifact tool
173
+ # writes to it unguarded already, being a tool rather than eval'd code; this
174
+ # is the same write reached a different way, so it gets the same exemption.
175
+ # Scoped to the one save, nothing the caller passes runs inside it.
176
+ artifact = configuration.safety_guards.without_guards do
177
+ model.store(name: name, content: content,
178
+ content_type: content_type, description: description)
179
+ end
180
+ if artifact.errors.any?
181
+ raise ArgumentError, "Could not save artifact: #{artifact.errors.full_messages.join('; ')}"
182
+ end
183
+
184
+ "#{base_url}#{engine_mount_path}/artifacts/#{artifact.slug}"
185
+ end
186
+
187
+ # Fully qualified on purpose. A bare `Artifact` here reads as
188
+ # RailsConsoleAi::Artifact to Ruby, but the enclosing scope is the singleton
189
+ # class — an anonymous module — so once Rails' autoloader is the one
190
+ # resolving it, const_missing has no namespace to derive from and goes
191
+ # looking for a top-level Artifact that does not exist. Referencing the full
192
+ # path is also what triggers the autoload in the first place; `defined?`
193
+ # would report "no engine" in a perfectly healthy app.
194
+ def artifact_model
195
+ RailsConsoleAi::Artifact
196
+ rescue NameError
197
+ nil
198
+ end
199
+
60
200
  # Enqueue an agent run. Returns the Integer session id immediately;
61
201
  # the actual work is picked up by `rake rails_console_ai:agents`.
62
202
  #
@@ -125,6 +265,7 @@ module RailsConsoleAi
125
265
  lines << " Model: #{c.resolved_model}"
126
266
  lines << " API key: #{masked_key}"
127
267
  lines << " Local URL: #{c.local_url}" if c.provider == :local
268
+ lines << " OpenRouter URL: #{c.openrouter_url}" if c.openrouter_url
128
269
  lines << " Max tokens: #{c.max_tokens || '(auto)'}"
129
270
  lines << " Temperature: #{c.temperature}"
130
271
  lines << " Timeout: #{c.timeout}s"
@@ -184,6 +325,7 @@ module RailsConsoleAi
184
325
  setup_skills_tables!(conn)
185
326
  setup_memories_tables!(conn)
186
327
  setup_agents_tables!(conn)
328
+ setup_artifacts_table!(conn)
187
329
 
188
330
  migrate!
189
331
  rescue => e
@@ -351,6 +493,80 @@ module RailsConsoleAi
351
493
  end
352
494
  end
353
495
 
496
+ def setup_artifacts_table!(conn)
497
+ table = 'rails_console_ai_artifacts'
498
+ return if conn.table_exists?(table)
499
+
500
+ conn.create_table(table) do |t|
501
+ t.string :name, limit: 255, null: false
502
+ # Stable handle derived from the name, so the assistant can be told
503
+ # "update the churn summary" in a later session and find the same row.
504
+ t.string :slug, limit: 255, null: false
505
+ t.text :description
506
+ t.string :content_type, limit: 100, null: false
507
+ t.text :content, null: false
508
+ t.integer :byte_size, default: 0, null: false
509
+ t.integer :revision, default: 1, null: false
510
+ # Addressed by an unguessable token rather than a sequential id, so a
511
+ # shared link can't be walked to the neighbouring artifact.
512
+ t.string :token, limit: 64, null: false
513
+ t.integer :session_id
514
+ t.string :created_by, limit: 255
515
+ t.datetime :created_at, null: false
516
+ t.datetime :updated_at, null: false
517
+ end
518
+
519
+ conn.add_index(table, :token, unique: true)
520
+ conn.add_index(table, :slug, unique: true)
521
+ conn.add_index(table, :created_at)
522
+ conn.add_index(table, :session_id)
523
+ $stdout.puts "\e[32mRailsConsoleAi: created #{table} table.\e[0m"
524
+ end
525
+
526
+ # Brings an artifacts table created before slugs existed up to date, and
527
+ # backfills a handle for every row already in it.
528
+ def upgrade_artifacts_table!(conn)
529
+ table = 'rails_console_ai_artifacts'
530
+ return [] unless conn.table_exists?(table)
531
+
532
+ added = []
533
+
534
+ unless conn.column_exists?(table, :revision)
535
+ conn.add_column(table, :revision, :integer, default: 1, null: false)
536
+ added << "#{table}.revision"
537
+ end
538
+
539
+ unless conn.column_exists?(table, :slug)
540
+ # Added nullable, backfilled, and only then made unique — an existing
541
+ # table has rows that need a handle before the index can be built.
542
+ conn.add_column(table, :slug, :string, limit: 255)
543
+ backfill_artifact_slugs!(conn, table)
544
+ conn.add_index(table, :slug, unique: true) unless conn.index_exists?(table, :slug)
545
+ added << "#{table}.slug"
546
+ end
547
+
548
+ added
549
+ end
550
+
551
+ def backfill_artifact_slugs!(conn, table)
552
+ require 'rails_console_ai/slash_commands'
553
+ seen = {}
554
+ rows = conn.select_all("SELECT id, name FROM #{table} WHERE slug IS NULL ORDER BY id")
555
+ rows.each do |row|
556
+ base = RailsConsoleAi::SlashCommands.slugify(row['name'])
557
+ base = "artifact-#{row['id']}" if base.empty?
558
+ slug = base
559
+ n = 1
560
+ # Two rows can share a name; the older one keeps the bare handle.
561
+ while seen.key?(slug) || conn.select_value("SELECT 1 FROM #{table} WHERE slug = #{conn.quote(slug)} LIMIT 1")
562
+ n += 1
563
+ slug = "#{base}-#{n}"
564
+ end
565
+ seen[slug] = true
566
+ conn.execute("UPDATE #{table} SET slug = #{conn.quote(slug)} WHERE id = #{row['id'].to_i}")
567
+ end
568
+ end
569
+
354
570
  def migrate!
355
571
  conn = session_connection
356
572
  table = 'rails_console_ai_sessions'
@@ -434,6 +650,13 @@ module RailsConsoleAi
434
650
  setup_memories_tables!(conn)
435
651
  setup_agents_tables!(conn)
436
652
 
653
+ if conn.table_exists?('rails_console_ai_artifacts')
654
+ migrations.concat(upgrade_artifacts_table!(conn))
655
+ else
656
+ setup_artifacts_table!(conn)
657
+ migrations << 'rails_console_ai_artifacts'
658
+ end
659
+
437
660
  [[:skills, 'rails_console_ai_skills'], [:memories, 'rails_console_ai_memories'], [:agents, 'rails_console_ai_agents']].each do |key, name|
438
661
  post = table_columns(conn, name)
439
662
  added = post - pre_columns[key]
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.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -106,6 +106,7 @@ files:
106
106
  - app/controllers/rails_console_ai/agent_versions_controller.rb
107
107
  - app/controllers/rails_console_ai/agents_controller.rb
108
108
  - app/controllers/rails_console_ai/application_controller.rb
109
+ - app/controllers/rails_console_ai/artifacts_controller.rb
109
110
  - app/controllers/rails_console_ai/memories_controller.rb
110
111
  - app/controllers/rails_console_ai/memory_versions_controller.rb
111
112
  - app/controllers/rails_console_ai/sessions_controller.rb
@@ -115,6 +116,7 @@ files:
115
116
  - app/helpers/rails_console_ai/sessions_helper.rb
116
117
  - app/models/rails_console_ai/agent.rb
117
118
  - app/models/rails_console_ai/agent_version.rb
119
+ - app/models/rails_console_ai/artifact.rb
118
120
  - app/models/rails_console_ai/memory.rb
119
121
  - app/models/rails_console_ai/memory_version.rb
120
122
  - app/models/rails_console_ai/session.rb
@@ -129,6 +131,8 @@ files:
129
131
  - app/views/rails_console_ai/agents/index.html.erb
130
132
  - app/views/rails_console_ai/agents/new.html.erb
131
133
  - app/views/rails_console_ai/agents/show.html.erb
134
+ - app/views/rails_console_ai/artifacts/index.html.erb
135
+ - app/views/rails_console_ai/artifacts/show.html.erb
132
136
  - app/views/rails_console_ai/memories/_form.html.erb
133
137
  - app/views/rails_console_ai/memories/diff.html.erb
134
138
  - app/views/rails_console_ai/memories/edit.html.erb
@@ -166,18 +170,21 @@ files:
166
170
  - lib/rails_console_ai/conversation_engine.rb
167
171
  - lib/rails_console_ai/engine.rb
168
172
  - lib/rails_console_ai/executor.rb
173
+ - lib/rails_console_ai/line_editor.rb
169
174
  - lib/rails_console_ai/prefixed_io.rb
170
175
  - lib/rails_console_ai/providers/anthropic.rb
171
176
  - lib/rails_console_ai/providers/base.rb
172
177
  - lib/rails_console_ai/providers/bedrock.rb
173
178
  - lib/rails_console_ai/providers/local.rb
174
179
  - lib/rails_console_ai/providers/openai.rb
180
+ - lib/rails_console_ai/providers/openrouter.rb
175
181
  - lib/rails_console_ai/railtie.rb
176
182
  - lib/rails_console_ai/repl.rb
177
183
  - lib/rails_console_ai/safety_guards.rb
178
184
  - lib/rails_console_ai/session_logger.rb
179
185
  - lib/rails_console_ai/skill_loader.rb
180
186
  - lib/rails_console_ai/slack_bot.rb
187
+ - lib/rails_console_ai/slash_commands.rb
181
188
  - lib/rails_console_ai/storage/base.rb
182
189
  - lib/rails_console_ai/storage/database_storage.rb
183
190
  - lib/rails_console_ai/storage/file_storage.rb