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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +102 -10
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
- data/app/models/rails_console_ai/artifact.rb +231 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
- data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
- data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -1
- data/lib/rails_console_ai/channel/console.rb +132 -18
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +114 -15
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +231 -61
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +26 -1
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +17 -37
- data/lib/rails_console_ai/providers/openai.rb +31 -10
- data/lib/rails_console_ai/providers/openrouter.rb +108 -0
- data/lib/rails_console_ai/slack_bot.rb +58 -23
- data/lib/rails_console_ai/slash_commands.rb +150 -0
- data/lib/rails_console_ai/sub_agent.rb +35 -2
- data/lib/rails_console_ai/tools/registry.rb +233 -3
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +223 -0
- 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
|
|
data/lib/rails_console_ai.rb
CHANGED
|
@@ -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.
|
|
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
|