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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae704dc4b0f74645775e86661d9cb6d004df5c1e0b338ab012564d87317bff4c
4
- data.tar.gz: 0f0f9c24eb4c7aad94175bde55c36571df238d3b067bbe124a49d341f7df0e03
3
+ metadata.gz: 0b332f5004d3946e6ad603de4c29198ebf79e912a095248d8c76220edd991b59
4
+ data.tar.gz: 05c4c328d8cf1f6419c3794505dce7279c9fa30d142d4d56f7fcdfa52e61ccad
5
5
  SHA512:
6
- metadata.gz: 77f9088650c56287f17f4ef4e498537fdb6ddfc2a62b558085aa0cd717f02ddaa44379d427416bcc5b4ac2a6826105d361b1e9a25caacb400f25f60f369c4d43
7
- data.tar.gz: 03f1b9c35af746872ac7ec9b9a4cfb5e630bcf66b006cef254501efdef0b4d04187be161fd544ffc52248cee85fe45a1ec3e01fc62f371db5c3a453b60705971
6
+ metadata.gz: 3a85e28e935511e28dd3ac53fe105cfd76958bcb6cbdde4589ece30e28cfad64187f0a84c36ed76b65a096460dc304f37002baa1e549f8058bc45d23995bd0c1
7
+ data.tar.gz: bad4c6371a4897d0363e02a2569161cd874b5cb83319108e7bf9002c311e4559988ff6bb369802cd2da3897701d5c60129b1468cf1a650842aefc53b7c14d8d9
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.38.0]
6
+
7
+ - Support the Claude Sonnet 5.5 model family
8
+ - Show more detail in Bedrock error messages
9
+ - Link Slack sessions to a session page showing the Slack thread
10
+
11
+ ## [0.37.0]
12
+
13
+ - Add artifacts for saving and viewing output in the browser
14
+ - Name artifacts and revise them in place
15
+ - Add a Copy button to the artifact viewer
16
+ - Surface responses that were cut off at the token limit
17
+ - Give sub-agents access to the memory index
18
+ - Support Claude Opus 5.5 as the default model
19
+ - Add a cheaper Sonnet model tier
20
+
5
21
  ## [0.36.0]
6
22
 
7
23
  - Add an OpenRouter provider with access to 400+ models
data/README.md CHANGED
@@ -81,7 +81,9 @@ end
81
81
  | `/compact` | Compress history into a summary (saves tokens) |
82
82
  | `/usage` | Show token stats |
83
83
  | `/cost` | Show per-model cost breakdown |
84
- | `/think` | Upgrade to thinking model (Opus) for the rest of the session |
84
+ | `/think` | Switch to the thinking tier (Opus 5.5 at high effort) for the rest of the session |
85
+ | `/unthink` | Switch back to the default tier |
86
+ | `/cheap` | Switch to the cheap tier (Sonnet 5) for faster, cheaper routine lookups |
85
87
  | `/debug` | Toggle debug summaries (context stats, cost per call) |
86
88
  | `/expand <id>` | Show full omitted output |
87
89
  | `/context` | Show conversation history as sent to the LLM |
@@ -123,7 +125,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
123
125
 
124
126
  - **Tool use** — AI introspects your schema, models, files (Ruby, ERB, HTML, JS, CSS, YAML, etc), and code to write accurate queries
125
127
  - **Multi-step plans** — complex tasks are broken into steps, executed sequentially with `step1`/`step2` references
126
- - **Two-tier models** — defaults to Sonnet for speed/cost; `/think` upgrades to Opus when you need it
128
+ - **Three model tiers** — defaults to Opus 5.5 at medium effort; `/think` raises it to high effort, `/cheap` drops to Sonnet 5
127
129
  - **Cost tracking** — `/cost` shows per-model token usage and estimated spend
128
130
  - **Skills** — predefined procedures with guard bypasses that the AI activates on demand
129
131
  - **Memories** — AI saves what it learns about your app across sessions
@@ -132,6 +134,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
132
134
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
133
135
  - **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
134
136
  - **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
137
+ - **Artifacts** — reports, exports and pages are saved and served at `/rails_console_ai/artifacts` instead of scrolling past in the console
135
138
  - **Sub-agents** — delegate multi-step investigations to a separate LLM context that returns only a concise summary, keeping the main conversation lean
136
139
  - **Safe mode** — configurable guards that block side effects (DB writes, HTTP mutations, email delivery) during AI code execution
137
140
 
@@ -183,7 +186,7 @@ The markdown body becomes additional system prompt instructions for the sub-agen
183
186
  4. It runs the investigation (up to `max_rounds` tool calls)
184
187
  5. The main conversation receives only: `"Sub-agent result: User 56653 is on shard 5."`
185
188
 
186
- Sub-agents have access to read-only memory tools (`recall_memory`, `recall_memories`), code execution (`execute_code`), and all schema/code introspection tools. They cannot ask the user questions, write memories, or spawn further sub-agents.
189
+ Sub-agents have access to read-only memory tools (`recall_memory`, `recall_memories`), code execution (`execute_code`), and all schema/code introspection tools. Their system prompt carries the same memory index and pinned memories as the main conversation, so they check memories before investigating. They cannot ask the user questions, write memories, activate skills, or spawn further sub-agents.
187
190
 
188
191
  ### Configuration
189
192
 
@@ -307,9 +310,40 @@ Skills and global `bypass_guards_for_methods` coexist — use config-level bypas
307
310
  - **`d`** at the `Execute? [y/N/danger]` prompt disables guards for that single execution
308
311
  - When a guard blocks an operation, the user is prompted: `Re-run with safe mode disabled? [y/N]`
309
312
 
313
+ ## Artifacts
314
+
315
+ When the answer is a document rather than a sentence — a report, an export, a page — the
316
+ assistant calls `save_artifact` and hands back a URL instead of printing it into the console.
317
+ Artifacts are listed at `/rails_console_ai/artifacts`, named with the day they were made
318
+ (`20260911_Q3 churn summary`, addressed as `20260911-q3-churn-summary`), and saving under an
319
+ existing name replaces it and bumps its revision.
320
+
321
+ **Saving from Ruby.** Anything the assistant writes into `save_artifact` has to fit inside its
322
+ response, and a response has a hard output-token cap — a 40KB file does not fit. Rather than
323
+ being cut off mid-argument, content the console already holds should go straight in:
324
+
325
+ ```ruby
326
+ html = S3.read_file("mathnasium_calendar_move_plan.html")
327
+ RailsConsoleAi.save_artifact(name: "Mathnasium migration", content: html)
328
+ # => "https://secure.example.com/rails_console_ai/artifacts/20260911-mathnasium-migration"
329
+ ```
330
+
331
+ The bytes never pass through the model, so this is also the only way to store something
332
+ *verbatim* — anything retyped by the assistant is its recollection of the file, not the file.
333
+ `content_type:` is inferred when omitted, and the naming and revision rules are the same as the
334
+ tool's. The assistant knows to reach for this itself when content is already in a variable.
335
+
310
336
  ## LLM Providers
311
337
 
312
- RailsConsoleAi supports five LLM providers. Each uses a two-tier model system: a default model for speed/cost, and a thinking model activated via `/think` or by saying "think harder".
338
+ RailsConsoleAi supports five LLM providers. Each session runs on one of three tiers:
339
+
340
+ | Tier | Switch with | Model setting | Effort setting | Claude default |
341
+ |---|---|---|---|---|
342
+ | default | `/unthink` | `config.model` | `config.effort` | Opus 5.5, `medium` |
343
+ | thinking | `/think`, or say "think harder" | `config.thinking_model` | `config.thinking_effort` | Opus 5.5, `high` |
344
+ | cheap | `/cheap` | `config.cheap_model` | none sent | Sonnet 5 |
345
+
346
+ Effort is sent as Anthropic's `output_config.effort` (`low`, `medium`, `high`, `xhigh`, `max`) and only to Claude models that accept it. It is omitted for Haiku 4.5 and non-Claude models. Set `config.effort = nil` to leave the model's own default in place.
313
347
 
314
348
  ### Anthropic (default)
315
349
 
@@ -320,7 +354,7 @@ RailsConsoleAi.configure do |config|
320
354
  end
321
355
  ```
322
356
 
323
- Default model: `claude-sonnet-5`. Thinking model: `claude-opus-5`. Prompt caching is enabled automatically.
357
+ Default and thinking model: `claude-opus-5-5`. Cheap model: `claude-sonnet-5`. Prompt caching is enabled automatically.
324
358
 
325
359
  ### OpenAI
326
360
 
@@ -341,13 +375,13 @@ Access 400+ models (Claude, GPT, Gemini, DeepSeek, Llama, etc.) via OpenRouter w
341
375
  RailsConsoleAi.configure do |config|
342
376
  config.provider = :openrouter
343
377
  config.api_key = 'sk-or-...' # or set OPENROUTER_API_KEY env var
344
- # config.model = 'anthropic/claude-sonnet-5' # default
378
+ # config.model = 'anthropic/claude-opus-5.5' # default
345
379
  # config.openrouter_site_url = 'https://yoursite.com' # attribution
346
380
  # config.openrouter_app_name = 'MyApp' # shown on openrouter.ai
347
381
  end
348
382
  ```
349
383
 
350
- Default model: `anthropic/claude-sonnet-5`. Thinking model: `anthropic/claude-opus-5`.
384
+ Default and thinking model: `anthropic/claude-opus-5.5`. Cheap model: `anthropic/claude-sonnet-5`. Effort is sent as OpenRouter's `verbosity` parameter.
351
385
 
352
386
  **Features:**
353
387
  - **Exact cost reporting**: The gem reports the actual USD cost from OpenRouter (`$0.0431` vs estimated `~$0.04`)
@@ -369,8 +403,9 @@ gem 'aws-sdk-bedrockruntime'
369
403
  RailsConsoleAi.configure do |config|
370
404
  config.provider = :bedrock
371
405
  config.bedrock_region = 'us-east-1'
372
- # config.model = 'us.anthropic.claude-sonnet-5' # default
373
- # config.thinking_model = 'us.anthropic.claude-opus-5' # default
406
+ # config.model = 'us.anthropic.claude-opus-5-5' # default
407
+ # config.thinking_model = 'us.anthropic.claude-opus-5-5' # default
408
+ # config.cheap_model = 'us.anthropic.claude-sonnet-5' # default
374
409
  end
375
410
  ```
376
411
 
@@ -452,6 +487,9 @@ RailsConsoleAi.configure do |config|
452
487
  config.max_tool_rounds = 200 # safety cap on tool-use loops
453
488
  config.code_search_paths = %w[app] # directories for list_files / search_code
454
489
  config.line_editor = :auto # :auto, :reline (live "/" menu), :readline
490
+ # Public base URL, so links the assistant hands out (artifacts) are absolute.
491
+ # Falls back to the app's default_url_options, then to a relative path.
492
+ config.base_url = 'https://secure.example.com'
455
493
 
456
494
  # Runaway-loop circuit breakers (see "Runaway sessions" below)
457
495
  config.token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
@@ -628,12 +666,12 @@ RailsConsoleAi.abort_agent(id)
628
666
  ```ruby
629
667
  RailsConsoleAi.run_agent(
630
668
  "Trace why nightly billing is double-charging some accounts",
631
- use_thinking_model: true, # run on the thinking-tier model (e.g. Opus)
669
+ use_thinking_model: true, # run on the thinking tier (Opus 5.5 at high effort)
632
670
  max_wall_clock_seconds: 1800 # hard kill after 30 minutes; pass nil for no cap
633
671
  )
634
672
  ```
635
673
 
636
- - `use_thinking_model:` (default `false`) — switches the run to `config.thinking_model` (or the provider default thinking model) for the duration of the agent. Useful for harder, multi-step problems.
674
+ - `use_thinking_model:` (default `false`) — switches the run to the thinking tier (`config.thinking_model` at `config.thinking_effort`) for the duration of the agent. Useful for harder, multi-step problems.
637
675
  - `max_wall_clock_seconds:` (default `600`) — hard ceiling on wall-clock time. If the run exceeds the cap, the worker thread is killed and the session is marked `status='failed'` with `error_message: "exceeded max_wall_clock_seconds (Ns)"`. Pass `nil` to opt out of any cap.
638
676
 
639
677
  These (along with any future per-run options) are stored in a JSON `options` column on the session row, so they survive the handoff to the background runner.
@@ -0,0 +1,67 @@
1
+ module RailsConsoleAi
2
+ class ArtifactsController < RailsConsoleAi::ApplicationController
3
+ before_action :load_artifact, only: [:show, :raw, :download, :destroy]
4
+
5
+ # Artifact content is written by the model, from data the model read — some
6
+ # of which other people wrote. Treat it as untrusted and never let it execute
7
+ # on this origin.
8
+ #
9
+ # `sandbox` with no tokens is the strictest form: unique opaque origin, no
10
+ # scripts, no forms, no popups. It is set as a *header* rather than relying on
11
+ # the iframe's sandbox attribute alone, because the raw URL can be opened
12
+ # directly in a tab, where no iframe attribute applies. nosniff stops a
13
+ # text/plain artifact being re-interpreted as something executable.
14
+ ARTIFACT_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:".freeze
15
+
16
+ def index
17
+ @artifacts = Artifact.newest_first
18
+ @q = params[:q].to_s.strip
19
+ unless @q.empty?
20
+ needle = "%#{@q.downcase}%"
21
+ @artifacts = @artifacts.where(
22
+ 'LOWER(name) LIKE :q OR LOWER(COALESCE(description, \'\')) LIKE :q', q: needle
23
+ )
24
+ end
25
+ @artifacts = @artifacts.limit(200).to_a
26
+ end
27
+
28
+ def show
29
+ end
30
+
31
+ # The bytes themselves, sandboxed. The show page frames this URL.
32
+ def raw
33
+ send_artifact(disposition: 'inline')
34
+ end
35
+
36
+ def download
37
+ send_artifact(disposition: 'attachment')
38
+ end
39
+
40
+ def destroy
41
+ @artifact.destroy
42
+ redirect_to artifacts_path, notice: "Artifact \"#{@artifact.name}\" deleted."
43
+ end
44
+
45
+ private
46
+
47
+ def send_artifact(disposition:)
48
+ response.headers['Content-Security-Policy'] = ARTIFACT_CSP
49
+ response.headers['X-Content-Type-Options'] = 'nosniff'
50
+ # Belt and braces for older browsers that honour it.
51
+ response.headers['X-Frame-Options'] = 'SAMEORIGIN'
52
+
53
+ send_data @artifact.content,
54
+ type: "#{@artifact.content_type}; charset=utf-8",
55
+ disposition: disposition,
56
+ filename: @artifact.filename
57
+ end
58
+
59
+ # Addressed by slug, so the URL says what it points at. Tokens still resolve:
60
+ # links handed out before slugs existed, or shared from an older version,
61
+ # must keep working.
62
+ def load_artifact
63
+ @artifact = Artifact.find_by_handle(params[:id])
64
+ redirect_to artifacts_path, alert: 'Artifact not found.' unless @artifact
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,231 @@
1
+ require 'securerandom'
2
+ require 'rails_console_ai/slash_commands'
3
+
4
+ module RailsConsoleAi
5
+ # A file the assistant produced — an HTML report, a CSV extract, a JSON dump —
6
+ # kept so it can be looked at in the admin UI instead of scrolling past in the
7
+ # console. Unlike skills and agents these are outputs, not configuration, so
8
+ # there is no propose/approve cycle and no file-backed source.
9
+ class Artifact < ActiveRecord::Base
10
+ self.table_name = 'rails_console_ai_artifacts'
11
+
12
+ # Content types the UI knows how to serve. Anything outside this list is
13
+ # rejected rather than passed through: the value reaches a Content-Type
14
+ # header, and letting the model choose it freely is how you end up serving
15
+ # something the browser treats as a script on your own origin.
16
+ CONTENT_TYPES = {
17
+ 'text/html' => { ext: 'html', renders: true },
18
+ 'text/markdown' => { ext: 'md', renders: false },
19
+ 'text/csv' => { ext: 'csv', renders: false },
20
+ 'application/json' => { ext: 'json', renders: false },
21
+ 'text/plain' => { ext: 'txt', renders: false }
22
+ }.freeze
23
+
24
+ # A generated report is a few hundred KB at the very outside. The cap is here
25
+ # because the content lands in a text column and in a single console turn's
26
+ # memory, and a runaway generation shouldn't be able to fill either.
27
+ MAX_BYTES = 2_000_000
28
+
29
+ belongs_to :session,
30
+ class_name: 'RailsConsoleAi::Session',
31
+ foreign_key: :session_id,
32
+ optional: true
33
+
34
+ validates :name, presence: true, length: { maximum: 255 }
35
+ validates :slug, presence: true, uniqueness: { case_sensitive: false }
36
+ validates :content, presence: true
37
+ validates :content_type, inclusion: {
38
+ in: CONTENT_TYPES.keys,
39
+ message: "must be one of: #{CONTENT_TYPES.keys.join(', ')}"
40
+ }
41
+ validate :content_within_limit
42
+
43
+ before_validation :assign_token
44
+ before_validation :assign_slug
45
+ before_validation :measure_content
46
+
47
+ scope :newest_first, -> { order(created_at: :desc) }
48
+
49
+ def self.connection
50
+ klass = RailsConsoleAi.configuration.connection_class
51
+ if klass
52
+ klass = Object.const_get(klass) if klass.is_a?(String)
53
+ klass.connection
54
+ else
55
+ super
56
+ end
57
+ end
58
+
59
+ # False when the gem's tables haven't been installed, so callers can say
60
+ # "run ai_db_setup" instead of surfacing a raw ActiveRecord error.
61
+ def self.available?
62
+ connection.table_exists?(table_name)
63
+ rescue ::ActiveRecord::ActiveRecordError, ::ActiveRecord::NoDatabaseError
64
+ false
65
+ end
66
+
67
+ def self.find_by_token!(token)
68
+ find_by!(token: token.to_s)
69
+ end
70
+
71
+ # How the assistant addresses an artifact across turns and sessions: the
72
+ # slug it was given, the name a human typed, or the token from a URL. Name
73
+ # is matched case-insensitively because the user will not retype it exactly.
74
+ def self.find_by_handle(handle)
75
+ handle = handle.to_s.strip
76
+ return nil if handle.empty?
77
+
78
+ slug = RailsConsoleAi::SlashCommands.slugify(handle)
79
+ find_by(slug: slug) ||
80
+ find_by(slug: handle) ||
81
+ where('LOWER(name) = ?', handle.downcase).first ||
82
+ find_by(token: handle) ||
83
+ most_recent_matching(handle)
84
+ end
85
+
86
+ # The handle a new artifact with this name would take, minus any suffix the
87
+ # uniqueness check would add.
88
+ def self.slug_for(name)
89
+ RailsConsoleAi::SlashCommands.slugify(name)
90
+ end
91
+
92
+ # Leading "20260911_" / "20260911-", as added to new names.
93
+ DATE_PREFIX = /\A\d{8}[_-]/.freeze
94
+
95
+ def self.date_prefix_for(time = Time.now)
96
+ "#{time.strftime('%Y%m%d')}_"
97
+ end
98
+
99
+ # The slug mirrors the name, date and all, so the two always correspond:
100
+ # "20260911_Dog on a bike" is addressed as 20260911-dog-on-a-bike.
101
+ def self.base_slug_for(name)
102
+ slug_for(name).sub(DATE_PREFIX, '')
103
+ end
104
+
105
+ def base_slug
106
+ slug.to_s.sub(DATE_PREFIX, '')
107
+ end
108
+
109
+ # Resolves an undated handle to the most recent artifact carrying it, so a
110
+ # user can say "the churn report" without knowing which day's it was.
111
+ # Only a convenience for reading and deleting — saving matches exactly.
112
+ def self.most_recent_matching(handle)
113
+ base = base_slug_for(handle)
114
+ return nil if base.empty?
115
+
116
+ candidates = where('slug = ? OR slug LIKE ?', base, "________-#{base}")
117
+ .order(created_at: :desc).to_a
118
+ candidates.find { |a| a.base_slug == base }
119
+ end
120
+
121
+ # Applies the date stamp unless the caller already supplied one, or the app
122
+ # has turned stamping off.
123
+ def self.stamped_name(name, time = Time.now)
124
+ name = name.to_s.strip
125
+ # A blank name must stay blank: stamping it would smuggle an unnamed
126
+ # artifact past the presence validation as "20260911_".
127
+ return name if name.empty?
128
+ return name unless RailsConsoleAi.configuration.artifact_date_prefix
129
+ return name if name =~ DATE_PREFIX
130
+ "#{date_prefix_for(time)}#{name}"
131
+ end
132
+
133
+ # The one place an artifact is written. Both callers reach it: the
134
+ # save_artifact tool, which carries content the model typed out, and
135
+ # RailsConsoleAi.save_artifact, which carries a string the console already
136
+ # holds. Saving under a name that already exists is an edit, not a second
137
+ # copy — that is what lets a user say "update the churn report" two sessions
138
+ # later. Returns the (possibly unsaved) record; callers report its errors.
139
+ def self.store(name:, content:, content_type:, description: nil)
140
+ stamped = stamped_name(name)
141
+ existing = find_by(slug: slug_for(stamped))
142
+ artifact = existing || new
143
+
144
+ artifact.name = stamped
145
+ artifact.content_type = content_type
146
+ artifact.content = content
147
+ artifact.description = description if description || artifact.new_record?
148
+ artifact.session_id = RailsConsoleAi.current_session_id
149
+ artifact.created_by ||= RailsConsoleAi.current_user
150
+ artifact.revision = artifact.revision.to_i + 1 if existing
151
+
152
+ artifact.save
153
+ artifact
154
+ end
155
+
156
+ # Only consulted when a caller omits the type. Deliberately narrow: the value
157
+ # becomes a Content-Type header, so anything it isn't confident about lands on
158
+ # text/plain rather than being guessed into something the browser will run.
159
+ def self.infer_content_type(content)
160
+ sample = content.to_s.lstrip[0, 512].to_s
161
+ return 'text/html' if sample.match?(/\A<(!doctype html|html|\?xml|!--)/i) || sample.match?(/\A\s*<[a-z]+[\s>]/i)
162
+ return 'application/json' if sample.match?(/\A[\[{]/)
163
+ 'text/plain'
164
+ end
165
+
166
+ def extension
167
+ CONTENT_TYPES.dig(content_type, :ext) || 'txt'
168
+ end
169
+
170
+ # Whether the show page offers a rendered preview. Only HTML gets one, and
171
+ # even then it is rendered inside a sandboxed frame — see ArtifactsController.
172
+ def renderable?
173
+ !!CONTENT_TYPES.dig(content_type, :renders)
174
+ end
175
+
176
+ def filename
177
+ base = name.to_s.strip.downcase
178
+ .gsub(/[^a-z0-9]+/, '-')
179
+ .gsub(/-+/, '-')
180
+ .sub(/\A-/, '').sub(/-\z/, '')
181
+ base = "artifact-#{id}" if base.empty?
182
+ "#{base}.#{extension}"
183
+ end
184
+
185
+ def human_size
186
+ bytes = byte_size.to_i
187
+ return "#{bytes} B" if bytes < 1024
188
+ return "#{(bytes / 1024.0).round(1)} KB" if bytes < 1024 * 1024
189
+ "#{(bytes / 1024.0 / 1024.0).round(1)} MB"
190
+ end
191
+
192
+ private
193
+
194
+ def assign_token
195
+ self.token = SecureRandom.hex(16) if token.to_s.empty?
196
+ end
197
+
198
+ def assign_slug
199
+ return unless slug.to_s.empty?
200
+
201
+ base = self.class.slug_for(name)
202
+ # A name with nothing sluggable in it leaves just the date stamp, which
203
+ # would collide with the next such name saved the same day.
204
+ # The separator is already gone when nothing followed it, so match the
205
+ # date with the separator optional.
206
+ if base.sub(/\A\d{8}[_-]?/, '').empty?
207
+ stamp = base[/\A\d{8}/]
208
+ base = [stamp, "artifact-#{SecureRandom.hex(4)}"].compact.join('-')
209
+ end
210
+
211
+ candidate = base
212
+ n = 1
213
+ # Only reached when the caller did not mean to update an existing artifact
214
+ # (save_artifact looks the slug up first), so the newcomer takes a suffix.
215
+ while self.class.where(slug: candidate).where.not(id: id).exists?
216
+ n += 1
217
+ candidate = "#{base}-#{n}"
218
+ end
219
+ self.slug = candidate
220
+ end
221
+
222
+ def measure_content
223
+ self.byte_size = content.to_s.bytesize
224
+ end
225
+
226
+ def content_within_limit
227
+ return if content.to_s.bytesize <= MAX_BYTES
228
+ errors.add(:content, "is #{content.to_s.bytesize} bytes, over the #{MAX_BYTES} byte limit")
229
+ end
230
+ end
231
+ end
@@ -155,6 +155,7 @@
155
155
  <a href="<%= rails_console_ai.skills_path %>">Skills</a>
156
156
  <a href="<%= rails_console_ai.memories_path %>">Memories</a>
157
157
  <a href="<%= rails_console_ai.agents_path %>">Agents</a>
158
+ <a href="<%= rails_console_ai.artifacts_path %>">Artifacts</a>
158
159
  </div>
159
160
  </div>
160
161
  <div class="container">
@@ -0,0 +1,60 @@
1
+ <div class="btn-bar">
2
+ <form method="get" action="<%= artifacts_path %>" style="margin-left:auto;">
3
+ <input type="text" name="q" value="<%= @q %>" placeholder="Search artifacts…" style="padding:6px 10px; border:1px solid #ccc; border-radius:4px;">
4
+ </form>
5
+ </div>
6
+
7
+ <% if @artifacts.empty? %>
8
+ <div class="meta-card">
9
+ <% if @q.present? %>
10
+ <p class="text-muted">No artifacts match “<%= @q %>”. <%= link_to 'Show all', artifacts_path %>.</p>
11
+ <% else %>
12
+ <p class="text-muted">
13
+ No artifacts yet. The assistant creates them with the <code>save_artifact</code> tool —
14
+ ask it for a report, summary or export and it will save one here instead of printing it into the console.
15
+ </p>
16
+ <% end %>
17
+ </div>
18
+ <% else %>
19
+ <table>
20
+ <thead>
21
+ <tr>
22
+ <th>Name</th>
23
+ <th title="How the assistant addresses this artifact">Handle</th>
24
+ <th>Description</th>
25
+ <th>Type</th>
26
+ <th>Size</th>
27
+ <th>Created by</th>
28
+ <th>Session</th>
29
+ <th>Updated</th>
30
+ <th></th>
31
+ </tr>
32
+ </thead>
33
+ <tbody>
34
+ <% @artifacts.each do |a| %>
35
+ <tr>
36
+ <td>
37
+ <strong><%= link_to a.name, artifact_path(a.slug) %></strong>
38
+ <% if a.revision.to_i > 1 %>
39
+ <span class="badge badge-status-queued">rev <%= a.revision %></span>
40
+ <% end %>
41
+ </td>
42
+ <td class="mono"><%= a.slug %></td>
43
+ <td class="query-cell"><%= a.description %></td>
44
+ <td class="mono"><%= a.content_type %></td>
45
+ <td class="mono"><%= a.human_size %></td>
46
+ <td><%= a.created_by || '—' %></td>
47
+ <td>
48
+ <% if a.session_id %>
49
+ <%= link_to "##{a.session_id}", session_path(a.session_id) %>
50
+ <% else %>
51
+ —
52
+ <% end %>
53
+ </td>
54
+ <td><%= a.updated_at.strftime('%Y-%m-%d %H:%M') %></td>
55
+ <td><%= link_to 'Download', download_artifact_path(a.slug), class: 'btn btn-secondary' %></td>
56
+ </tr>
57
+ <% end %>
58
+ </tbody>
59
+ </table>
60
+ <% end %>
@@ -0,0 +1,119 @@
1
+ <div class="btn-bar">
2
+ <%= link_to '← All artifacts', artifacts_path, class: 'btn btn-secondary' %>
3
+ <%= link_to 'Download', download_artifact_path(@artifact.slug), class: 'btn' %>
4
+ <%= link_to 'Open raw', raw_artifact_path(@artifact.slug), class: 'btn btn-secondary', target: '_blank', rel: 'noopener noreferrer' %>
5
+ <%# Copies the artifact's source, exactly as stored. Fetches the raw URL on
6
+ click rather than embedding a second copy of the content in this page. %>
7
+ <button type="button" class="btn btn-secondary" id="copy-artifact"
8
+ data-url="<%= raw_artifact_path(@artifact.slug) %>"
9
+ title="Copy the artifact's content to the clipboard">Copy</button>
10
+ <%# button_to, not link_to: the engine layout loads no JavaScript, so a
11
+ data-method link would quietly do nothing. Matches skills/agents. %>
12
+ <span style="margin-left:auto;">
13
+ <%= button_to 'Delete', artifact_path(@artifact.slug), method: :delete,
14
+ class: 'btn btn-danger', form: { style: 'display:inline' },
15
+ data: { confirm: 'Delete this artifact? This cannot be undone.' } %>
16
+ </span>
17
+ </div>
18
+
19
+ <div class="meta-card">
20
+ <h2 style="font-size:18px; margin-bottom:4px;"><%= @artifact.name %></h2>
21
+ <% if @artifact.description.present? %>
22
+ <p class="text-muted" style="margin-bottom:16px;"><%= @artifact.description %></p>
23
+ <% end %>
24
+ <div class="meta-grid">
25
+ <div class="meta-item">
26
+ <label title="How the assistant addresses this artifact">Handle</label>
27
+ <span class="mono"><%= @artifact.slug %></span>
28
+ </div>
29
+ <div class="meta-item"><label>Revision</label><span><%= @artifact.revision %></span></div>
30
+ <div class="meta-item"><label>Type</label><span class="mono"><%= @artifact.content_type %></span></div>
31
+ <div class="meta-item"><label>Size</label><span><%= @artifact.human_size %></span></div>
32
+ <div class="meta-item"><label>Created by</label><span><%= @artifact.created_by || '—' %></span></div>
33
+ <div class="meta-item">
34
+ <label>Session</label>
35
+ <span>
36
+ <% if @artifact.session_id %>
37
+ <%= link_to "##{@artifact.session_id}", session_path(@artifact.session_id) %>
38
+ <% else %>
39
+ —
40
+ <% end %>
41
+ </span>
42
+ </div>
43
+ <div class="meta-item"><label>Created</label><span><%= @artifact.created_at.strftime('%Y-%m-%d %H:%M') %></span></div>
44
+ <div class="meta-item"><label>Updated</label><span><%= @artifact.updated_at.strftime('%Y-%m-%d %H:%M') %></span></div>
45
+ </div>
46
+ </div>
47
+
48
+ <% if @artifact.renderable? %>
49
+ <div class="meta-card" style="padding:0; overflow:hidden;">
50
+ <%#
51
+ Sandboxed with no allow-same-origin and no allow-scripts: the frame gets an
52
+ opaque origin, so even if the content carried a script it could not run, and
53
+ could not reach this page's session if it did. The same restrictions are set
54
+ as a CSP header on the raw response, which is what protects the "Open raw"
55
+ link where no iframe attribute applies.
56
+ %>
57
+ <iframe src="<%= raw_artifact_path(@artifact.slug) %>"
58
+ sandbox
59
+ referrerpolicy="no-referrer"
60
+ title="<%= @artifact.name %>"
61
+ style="width:100%; height:70vh; border:0; background:#fff; display:block;"></iframe>
62
+ </div>
63
+ <p class="text-muted" style="font-size:12px; margin-top:8px;">
64
+ Rendered in a sandboxed frame — scripts and network requests are blocked, so inline CSS shows but JavaScript will not run.
65
+ </p>
66
+ <% else %>
67
+ <div class="terminal">
68
+ <pre><%= @artifact.content %></pre>
69
+ </div>
70
+ <% end %>
71
+
72
+ <%# The only JavaScript in the admin UI. nonce: true picks up the host app's CSP
73
+ nonce when it has one, and is a no-op otherwise. %>
74
+ <%= javascript_tag nonce: true do %>
75
+ (function () {
76
+ var button = document.getElementById('copy-artifact');
77
+ if (!button) return;
78
+ var label = button.textContent;
79
+
80
+ function flash(text) {
81
+ button.textContent = text;
82
+ setTimeout(function () { button.textContent = label; }, 2000);
83
+ }
84
+
85
+ // navigator.clipboard needs a secure context (https or localhost). Over
86
+ // plain http elsewhere, fall back to the old textarea + execCommand route.
87
+ function writeClipboard(text) {
88
+ if (navigator.clipboard && window.isSecureContext) {
89
+ return navigator.clipboard.writeText(text);
90
+ }
91
+ return new Promise(function (resolve, reject) {
92
+ var area = document.createElement('textarea');
93
+ area.value = text;
94
+ area.setAttribute('readonly', '');
95
+ area.style.position = 'fixed';
96
+ area.style.top = '-1000px';
97
+ document.body.appendChild(area);
98
+ area.select();
99
+ var ok = false;
100
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
101
+ document.body.removeChild(area);
102
+ ok ? resolve() : reject(new Error('execCommand copy failed'));
103
+ });
104
+ }
105
+
106
+ button.addEventListener('click', function () {
107
+ button.disabled = true;
108
+ fetch(button.getAttribute('data-url'), { credentials: 'same-origin' })
109
+ .then(function (response) {
110
+ if (!response.ok) throw new Error('HTTP ' + response.status);
111
+ return response.text();
112
+ })
113
+ .then(writeClipboard)
114
+ .then(function () { flash('Copied'); })
115
+ .catch(function () { flash('Copy failed'); })
116
+ .then(function () { button.disabled = false; });
117
+ });
118
+ })();
119
+ <% end %>