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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37ef99b1cc4572e89283e5210b15e6110bac2b0adc758a1b7d222c603c4557c2
4
- data.tar.gz: 511f76003fdc00ce0104b506eaa71d7de918e3cf1f62eebd395308461caeefd1
3
+ metadata.gz: 0b332f5004d3946e6ad603de4c29198ebf79e912a095248d8c76220edd991b59
4
+ data.tar.gz: 05c4c328d8cf1f6419c3794505dce7279c9fa30d142d4d56f7fcdfa52e61ccad
5
5
  SHA512:
6
- metadata.gz: e6cb2ccc4f03fbcafff6d7edcace1c80fd76a99692404f7e05aa039e9276665b07e03ab70b12043635b37742c5e05ffe1fa9fd42f77e25de6daff329c90e0dfc
7
- data.tar.gz: ee6acd8f33f2d13c921832fd1d4470e9d08bd8a795ec45d7ebb3c7b67ae7ca3fb6475b46796192dcd4a0b2a48d66b0bbb8bc2b2e47e498e9785c12dad7eefba4
6
+ metadata.gz: 3a85e28e935511e28dd3ac53fe105cfd76958bcb6cbdde4589ece30e28cfad64187f0a84c36ed76b65a096460dc304f37002baa1e549f8058bc45d23995bd0c1
7
+ data.tar.gz: bad4c6371a4897d0363e02a2569161cd874b5cb83319108e7bf9002c311e4559988ff6bb369802cd2da3897701d5c60129b1468cf1a650842aefc53b7c14d8d9
data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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
+
21
+ ## [0.36.0]
22
+
23
+ - Add an OpenRouter provider with access to 400+ models
24
+ - Report exact costs when a provider returns them
25
+ - Run any skill or agent as a slash command from the console
26
+ - Add a live completion menu for slash commands
27
+ - Support choosing between the Reline and Readline line editors
28
+ - Fix cost reporting in the Slack bot
29
+
5
30
  ## [0.35.0]
6
31
 
7
32
  - Extend prompt caching to the full conversation history
data/README.md CHANGED
@@ -81,13 +81,42 @@ 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 |
88
90
  | `/system` | Show the system prompt |
89
91
  | `/name <label>` | Name the session for easy resume |
90
92
 
93
+ #### Running skills and agents from the prompt
94
+
95
+ Every skill and agent is also a slash command, under a slug built from its name —
96
+ "Restart user trial" becomes `/restart-user-trial`. Type `/` and a menu of everything
97
+ available appears as you type; press Enter on a bare `/` to print the full list.
98
+
99
+ ```
100
+ ai> /restart-user-trial user 4821
101
+ ai> /find-shard jess@example.com
102
+ ```
103
+
104
+ The two behave differently on purpose:
105
+
106
+ - **A skill runs here.** Its recipe is prepended to your request and its guard
107
+ bypasses are installed on the live executor, so the assistant follows the
108
+ procedure in the current conversation with full context.
109
+ - **An agent runs elsewhere.** It gets its own context and tool loop, and only its
110
+ summary comes back into the conversation — the same isolation `delegate_task`
111
+ gives the model, now available to you directly. Its tokens are billed to the session.
112
+
113
+ Anything after the command name is passed along as your request. A skill invoked
114
+ bare (`/restart-user-trial`) just runs its procedure.
115
+
116
+ Completion needs [Reline](https://github.com/ruby/reline), bundled with Ruby >= 2.7.
117
+ Without it the gem falls back to Readline, where Tab completes instead of a live menu.
118
+ Force one or the other with `c.line_editor = :reline` / `:readline` (default `:auto`).
119
+
91
120
  Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
92
121
 
93
122
  Say "think harder" in any query to auto-upgrade to the thinking model for that session. After 5+ tool rounds, you'll also be prompted to switch.
@@ -96,7 +125,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
96
125
 
97
126
  - **Tool use** — AI introspects your schema, models, files (Ruby, ERB, HTML, JS, CSS, YAML, etc), and code to write accurate queries
98
127
  - **Multi-step plans** — complex tasks are broken into steps, executed sequentially with `step1`/`step2` references
99
- - **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
100
129
  - **Cost tracking** — `/cost` shows per-model token usage and estimated spend
101
130
  - **Skills** — predefined procedures with guard bypasses that the AI activates on demand
102
131
  - **Memories** — AI saves what it learns about your app across sessions
@@ -105,6 +134,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
105
134
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
106
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
107
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
108
138
  - **Sub-agents** — delegate multi-step investigations to a separate LLM context that returns only a concise summary, keeping the main conversation lean
109
139
  - **Safe mode** — configurable guards that block side effects (DB writes, HTTP mutations, email delivery) during AI code execution
110
140
 
@@ -156,7 +186,7 @@ The markdown body becomes additional system prompt instructions for the sub-agen
156
186
  4. It runs the investigation (up to `max_rounds` tool calls)
157
187
  5. The main conversation receives only: `"Sub-agent result: User 56653 is on shard 5."`
158
188
 
159
- 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.
160
190
 
161
191
  ### Configuration
162
192
 
@@ -280,9 +310,40 @@ Skills and global `bypass_guards_for_methods` coexist — use config-level bypas
280
310
  - **`d`** at the `Execute? [y/N/danger]` prompt disables guards for that single execution
281
311
  - When a guard blocks an operation, the user is prompted: `Re-run with safe mode disabled? [y/N]`
282
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
+
283
336
  ## LLM Providers
284
337
 
285
- RailsConsoleAi supports four 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.
286
347
 
287
348
  ### Anthropic (default)
288
349
 
@@ -293,7 +354,7 @@ RailsConsoleAi.configure do |config|
293
354
  end
294
355
  ```
295
356
 
296
- 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.
297
358
 
298
359
  ### OpenAI
299
360
 
@@ -306,6 +367,29 @@ end
306
367
 
307
368
  Default model: `gpt-5.3-codex`. OpenAI applies prompt caching automatically on their end for prompts over 1024 tokens.
308
369
 
370
+ ### OpenRouter
371
+
372
+ Access 400+ models (Claude, GPT, Gemini, DeepSeek, Llama, etc.) via OpenRouter with unified OpenAI-compatible API. Automatic prompt caching for Anthropic models, real-time cost tracking, and provider sticky routing for cache hits.
373
+
374
+ ```ruby
375
+ RailsConsoleAi.configure do |config|
376
+ config.provider = :openrouter
377
+ config.api_key = 'sk-or-...' # or set OPENROUTER_API_KEY env var
378
+ # config.model = 'anthropic/claude-opus-5.5' # default
379
+ # config.openrouter_site_url = 'https://yoursite.com' # attribution
380
+ # config.openrouter_app_name = 'MyApp' # shown on openrouter.ai
381
+ end
382
+ ```
383
+
384
+ Default and thinking model: `anthropic/claude-opus-5.5`. Cheap model: `anthropic/claude-sonnet-5`. Effort is sent as OpenRouter's `verbosity` parameter.
385
+
386
+ **Features:**
387
+ - **Exact cost reporting**: The gem reports the actual USD cost from OpenRouter (`$0.0431` vs estimated `~$0.04`)
388
+ - **Automatic prompt caching**: Anthropic-family models (`anthropic/claude-*`) get a top-level `cache_control` breakpoint for multi-turn cache hits
389
+ - **Sticky routing**: Session-based routing keeps requests on the same provider endpoint for cache warmth across the tool-use loop
390
+
391
+ **Timeout note:** OpenRouter adds latency from request queueing and provider fallbacks. If you're routing to reasoning models, you may want to raise `config.timeout` above the 30s default.
392
+
309
393
  ### AWS Bedrock
310
394
 
311
395
  Access frontier models (Claude, Mistral, DeepSeek, Llama) via your AWS account with pay-per-token pricing. No API key needed — authentication uses the AWS SDK credential chain (IAM roles, env vars, `~/.aws/credentials`).
@@ -319,8 +403,9 @@ gem 'aws-sdk-bedrockruntime'
319
403
  RailsConsoleAi.configure do |config|
320
404
  config.provider = :bedrock
321
405
  config.bedrock_region = 'us-east-1'
322
- # config.model = 'us.anthropic.claude-sonnet-5' # default
323
- # 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
324
409
  end
325
410
  ```
326
411
 
@@ -369,6 +454,9 @@ Before adopting a new Claude model, smoke-test it against the Anthropic or Bedro
369
454
  # Anthropic — provider inferred from the `claude-` prefix
370
455
  ANTHROPIC_API_KEY=sk-ant-... bin/smoke_model.rb --model claude-opus-4-8
371
456
 
457
+ # OpenRouter — provider inferred from the `provider/model` format
458
+ OPENROUTER_API_KEY=sk-or-... bin/smoke_model.rb --model anthropic/claude-opus-4
459
+
372
460
  # Bedrock — provider inferred from the regional `us.anthropic.` prefix.
373
461
  # Requires the aws-sdk-bedrockruntime gem and AWS credentials in the environment.
374
462
  bin/smoke_model.rb --model us.anthropic.claude-opus-4-8
@@ -391,13 +479,17 @@ Pricing, default max tokens, and parameter support (e.g. which families reject `
391
479
 
392
480
  ```ruby
393
481
  RailsConsoleAi.configure do |config|
394
- config.provider = :anthropic # :anthropic, :openai, :bedrock, :local
482
+ config.provider = :anthropic # :anthropic, :openai, :openrouter, :bedrock, :local
395
483
  config.auto_execute = false # true to skip confirmations
396
484
  config.session_logging = true # requires ai_db_setup
397
485
  config.temperature = 0.2
398
486
  config.timeout = 30 # HTTP timeout in seconds
399
487
  config.max_tool_rounds = 200 # safety cap on tool-use loops
400
488
  config.code_search_paths = %w[app] # directories for list_files / search_code
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'
401
493
 
402
494
  # Runaway-loop circuit breakers (see "Runaway sessions" below)
403
495
  config.token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
@@ -574,12 +666,12 @@ RailsConsoleAi.abort_agent(id)
574
666
  ```ruby
575
667
  RailsConsoleAi.run_agent(
576
668
  "Trace why nightly billing is double-charging some accounts",
577
- 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)
578
670
  max_wall_clock_seconds: 1800 # hard kill after 30 minutes; pass nil for no cap
579
671
  )
580
672
  ```
581
673
 
582
- - `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.
583
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.
584
676
 
585
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
@@ -1,17 +1,17 @@
1
1
  module RailsConsoleAi
2
2
  module SessionsHelper
3
- # All four usage buckets bill at their own rate. `input_tokens` is the uncached
4
- # remainder only — on a cached session it is a tiny fraction of the prompt, so
5
- # pricing input+output alone reads as near-zero for the sessions that actually
6
- # cost the most.
3
+ # The cache buckets have to be included: `input_tokens` is the uncached
4
+ # remainder only, so on a well-cached session pricing input+output alone reads
5
+ # as near-zero for exactly the sessions that cost the most.
7
6
  def estimated_cost(session)
8
- pricing = Configuration.pricing_for(session.model)
9
- return nil unless pricing
10
-
11
- (session.input_tokens * pricing[:input]) +
12
- (session.output_tokens * pricing[:output]) +
13
- (session.try(:cache_read_tokens).to_i * pricing[:cache_read]) +
14
- (session.try(:cache_write_tokens).to_i * pricing[:cache_write])
7
+ Configuration.estimate_cost(
8
+ session.model,
9
+ input: session.input_tokens,
10
+ output: session.output_tokens,
11
+ cache_read: session.try(:cache_read_tokens).to_i,
12
+ cache_write: session.try(:cache_write_tokens).to_i,
13
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl
14
+ )
15
15
  end
16
16
 
17
17
  def format_cost(session)
@@ -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 %>