rails_console_ai 0.35.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +102 -10
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
- data/app/models/rails_console_ai/artifact.rb +231 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
- data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
- data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -1
- data/lib/rails_console_ai/channel/console.rb +132 -18
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +114 -15
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +231 -61
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +26 -1
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +17 -37
- data/lib/rails_console_ai/providers/openai.rb +31 -10
- data/lib/rails_console_ai/providers/openrouter.rb +108 -0
- data/lib/rails_console_ai/slack_bot.rb +58 -23
- data/lib/rails_console_ai/slash_commands.rb +150 -0
- data/lib/rails_console_ai/sub_agent.rb +35 -2
- data/lib/rails_console_ai/tools/registry.rb +233 -3
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +223 -0
- metadata +8 -1
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,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` |
|
|
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
|
-
- **
|
|
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
|
|
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-
|
|
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-
|
|
323
|
-
# 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
|
|
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
|
|
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`
|
|
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
|
-
#
|
|
4
|
-
# remainder only
|
|
5
|
-
#
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 %>
|