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.
@@ -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
  #
@@ -185,6 +325,7 @@ module RailsConsoleAi
185
325
  setup_skills_tables!(conn)
186
326
  setup_memories_tables!(conn)
187
327
  setup_agents_tables!(conn)
328
+ setup_artifacts_table!(conn)
188
329
 
189
330
  migrate!
190
331
  rescue => e
@@ -352,6 +493,80 @@ module RailsConsoleAi
352
493
  end
353
494
  end
354
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
+
355
570
  def migrate!
356
571
  conn = session_connection
357
572
  table = 'rails_console_ai_sessions'
@@ -435,6 +650,13 @@ module RailsConsoleAi
435
650
  setup_memories_tables!(conn)
436
651
  setup_agents_tables!(conn)
437
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
+
438
660
  [[:skills, 'rails_console_ai_skills'], [:memories, 'rails_console_ai_memories'], [:agents, 'rails_console_ai_agents']].each do |key, name|
439
661
  post = table_columns(conn, name)
440
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.36.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