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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +49 -11
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- 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 +5 -0
- data/lib/rails_console_ai/channel/console.rb +12 -3
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +72 -12
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +97 -27
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +20 -0
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +1 -1
- data/lib/rails_console_ai/providers/openai.rb +1 -1
- data/lib/rails_console_ai/providers/openrouter.rb +9 -1
- data/lib/rails_console_ai/slack_bot.rb +40 -12
- data/lib/rails_console_ai/slash_commands.rb +1 -0
- data/lib/rails_console_ai/sub_agent.rb +28 -0
- data/lib/rails_console_ai/tools/registry.rb +231 -1
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +222 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b332f5004d3946e6ad603de4c29198ebf79e912a095248d8c76220edd991b59
|
|
4
|
+
data.tar.gz: 05c4c328d8cf1f6419c3794505dce7279c9fa30d142d4d56f7fcdfa52e61ccad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
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
|
-
- **
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
373
|
-
# config.thinking_model = 'us.anthropic.claude-opus-5'
|
|
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
|
|
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`
|
|
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 %>
|