rails_console_ai 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '083adc4ffd8b69f950fbc4ab18f2ac056e8642c6946c8ba46e67e19e4299e0f2'
4
- data.tar.gz: a01fef0fe45dfa157d638a7d4edb1ee5c8d9753be3a79956b3ebde220d3ee939
3
+ metadata.gz: 7eab1fd8d3a5c8c7a228206f0c723bfa845fa2d344471e0251659658c0c5e571
4
+ data.tar.gz: c607b6cfa9e6834612513f0c75dc1303386f79bcdd96bba203a4fb5d0549f32b
5
5
  SHA512:
6
- metadata.gz: 56b8fdcd0cf445d9480d4e56e252271241af017c8ca51e2a11af36d2158ce4a3a1fdf9670685f931b8cbd0994e6828e6bac08d10247e8bec1af8c66ad83c47f4
7
- data.tar.gz: 6ccb72b8b8e9b157b28722d1e532d2abf5df8037f4ba4f1f3635bb37ad8a3441e48d832d903f6f92b6dce80106082215368dde15626eb0fc41d93da60100f261
6
+ metadata.gz: 1acd49dcf3370eaab8a8fb8ef77c7dad4746fcb0e0eb2f78935b53c870228a9b53a2cef8dc82608ed94f5f2642de905070fe9e241e9a98d8f408653535ce30d7
7
+ data.tar.gz: bd7ec6fcf50788b45f47f6a7b1948a74fb83cf4ff7fba62687eb2d6e8c75743972f4e83525d87f57096c1a6cd114611a69c5b6e2299a88dea6b02063cdf09adc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.32.0]
6
+
7
+ - Switch default models to Claude Sonnet 5 and Opus 4.8, and rework pricing to match models by family so dated snapshots and Bedrock IDs are covered
8
+ - Add an abort mechanism for background sub-agents
9
+ - Require human approval before AI-proposed memory changes take effect
10
+ - Keep `explore_output` focused on exploring a previous command's output instead of spinning off into database reads
11
+ - Fix loading `ApplicationController` under the classic autoloader
12
+
5
13
  ## [0.31.0]
6
14
 
7
15
  - Simplify DB-backed skills, sub-agents, and memories to a single content field per record, replacing the separate frontmatter/body columns and streamlining the models, controllers, and forms
@@ -22,32 +30,6 @@ All notable changes to this project will be documented in this file.
22
30
  - Harden `Skill` / `Agent` model accessors to return safe defaults when newly added columns are not yet present
23
31
  - Fix built-in agent `.md` files with UTF-8 characters failing to load under US-ASCII locales
24
32
 
25
- ## [Unreleased]
26
-
27
- - New skill / memory / agent pages now have a "Paste a .md file" textarea at the top. Paste the contents of a Markdown file with YAML frontmatter (same format used by `.rails_console_ai/{skills,memories,agents}/*.md` and the gem's built-in agents), click "Parse pasted content ↓", and the form fields below are prefilled from the parse. You still click Create to actually save, so the normal proposed-status + version-row flow applies — and you can tweak the parsed content before saving. Useful for moving an existing on-disk record into the versioned DB store
28
- - `RailsConsoleAi::SkillLoader.parse`, `RailsConsoleAi::AgentLoader.parse`, and `RailsConsoleAi::Tools::MemoryTools.parse` are now public class methods. They return a parsed frontmatter+body hash, or `nil` on malformed input
29
-
30
- - Usage tracking for DB-backed skills, memories, and agents. New `use_count` and `last_used_at` columns are bumped atomically (one SQL `UPDATE … SET use_count = use_count + 1, last_used_at = NOW()`, no callbacks, no `updated_at` change) when:
31
- - `activate_skill` resolves a DB skill
32
- - `recall_memory` resolves a DB memory, or `recall_memories` returns a DB memory in its result set
33
- - `delegate_task` resolves a DB sub-agent
34
- File / built-in records have no DB row so they're not tracked (the web UI shows `—` for them). System-prompt summary inclusion does **not** count — counters only move when the AI actively invokes/loads the record. Surfaced in the index tables (with a "Sort: most used" toggle) and on each show page (Times activated / Times recalled / Times invoked, plus Last used)
35
- - Bugfix: `RailsConsoleAi.migrate!` now always re-runs the `setup_*_tables!` methods so column-add probes execute on upgrades. Previously, when the base table already existed, the outer `unless table_exists?` guard skipped column probes entirely, leaving new columns un-added and causing `NameError: undefined local variable or method 'status'` from `Skill#proposed?` on host apps that hadn't fully migrated
36
- - Bugfix: `Skill` / `Agent` model accessors for `status` / `approved_by` / `approved_at` / `use_count` / `last_used_at` are now defensive — they return safe defaults via `has_attribute?` rather than raising NameError when the column hasn't been added yet. The `status` inclusion validation is also gated on the column being present
37
-
38
- - Sub-agents can now be stored in the database, versioned, and approved through the web UI — same workflow as skills. DB-backed agents start as `proposed` and are invisible to `delegate_task` until a human clicks Approve at `/rails_console_ai/agents`; editing an approved agent reverts it to proposed. Built-in (gem-shipped) and file-based agents are pre-approved and continue working unchanged
39
- - New AI tools `save_agent` and `delete_agent` — the AI can now draft a sub-agent definition (lands as proposed, awaits human approval) and request deletion. `delegate_task` now emits a specific "awaiting human approval" error when the AI references a proposed DB agent
40
- - New web UI section at `/rails_console_ai/agents` with three-source listing (DB / FILE / BUILTIN badges) plus the full skills-style CRUD + version history + diff + restore + approve. Built-in agents are read-only with a "Create DB override" link that prefills the new-agent form
41
- - `ai_db_setup` / `ai_db_migrate` now also create `rails_console_ai_agents` and `rails_console_ai_agent_versions` tables idempotently
42
- - Fix: built-in agent .md files containing UTF-8 (em-dashes, smart quotes) now load correctly. `safe_load_builtin_agents` was using `File.read`'s locale-default encoding, which silently swallowed `Encoding::CompatibilityError` on US-ASCII locales
43
-
44
- - Skills and memories can now be stored in the database (in addition to the existing on-disk `.rails_console_ai/skills` and `.rails_console_ai/memories` files). DB-backed records are versioned — every save creates a `SkillVersion` / `MemoryVersion` row with `edited_by` and an optional change note
45
- - DB-backed skills start in a **proposed** state and cannot be activated by the AI until a human approves them in the web UI. Editing an approved skill reverts it to proposed. File-backed skills are unaffected (already git-tracked, considered pre-approved). Memories are not gated
46
- - `SkillLoader#load_all_skills` and `MemoryTools#load_all_memories` now return the union of DB and file records (DB wins on name collision). `SkillLoader#find_skill` / `skill_summaries` filter out proposed skills so the AI never sees them
47
- - `save_skill` / `save_memory` AI tools accept an optional `target` parameter (`"db"` default, `"file"` to write to disk) plus `change_note`. When the AI saves a DB skill, the tool response tells it the skill is awaiting human approval
48
- - New web UI sections at `/rails_console_ai/skills` and `/rails_console_ai/memories` provide list, view, create, edit, delete, version history, side-by-side diff, and restore. Skills also have an Approve button and PROPOSED / APPROVED badges. File-sourced records are surfaced but read-only in the UI
49
- - `ai_db_setup` / `ai_db_migrate` now create the new `rails_console_ai_skills`, `rails_console_ai_skill_versions`, `rails_console_ai_memories`, and `rails_console_ai_memory_versions` tables idempotently, plus the `status` / `approved_by` / `approved_at` columns on skills
50
-
51
33
  ## [0.29.0]
52
34
 
53
35
  - Allow steering Slack conversations mid-run by sending follow-up messages that are folded in as user guidance at the next tool-loop boundary
data/README.md CHANGED
@@ -291,7 +291,7 @@ RailsConsoleAi.configure do |config|
291
291
  end
292
292
  ```
293
293
 
294
- Default model: `claude-sonnet-4-6`. Thinking model: `claude-opus-4-6`. Prompt caching is enabled automatically.
294
+ Default model: `claude-sonnet-5`. Thinking model: `claude-opus-4-8`. Prompt caching is enabled automatically.
295
295
 
296
296
  ### OpenAI
297
297
 
@@ -317,8 +317,8 @@ gem 'aws-sdk-bedrockruntime'
317
317
  RailsConsoleAi.configure do |config|
318
318
  config.provider = :bedrock
319
319
  config.bedrock_region = 'us-east-1'
320
- # config.model = 'us.anthropic.claude-sonnet-4-6' # default
321
- # config.thinking_model = 'us.anthropic.claude-opus-4-6-v1' # default
320
+ # config.model = 'us.anthropic.claude-sonnet-5' # default
321
+ # config.thinking_model = 'us.anthropic.claude-opus-4-8' # default
322
322
  end
323
323
  ```
324
324
 
@@ -365,25 +365,25 @@ Before adopting a new Claude model, smoke-test it against the Anthropic or Bedro
365
365
 
366
366
  ```bash
367
367
  # Anthropic — provider inferred from the `claude-` prefix
368
- ANTHROPIC_API_KEY=sk-ant-... bin/smoke_model.rb --model claude-opus-4-7
368
+ ANTHROPIC_API_KEY=sk-ant-... bin/smoke_model.rb --model claude-opus-4-8
369
369
 
370
370
  # Bedrock — provider inferred from the regional `us.anthropic.` prefix.
371
371
  # Requires the aws-sdk-bedrockruntime gem and AWS credentials in the environment.
372
- bin/smoke_model.rb --model us.anthropic.claude-opus-4-7
372
+ bin/smoke_model.rb --model us.anthropic.claude-opus-4-8
373
373
 
374
374
  # Bedrock in another region
375
- bin/smoke_model.rb --model eu.anthropic.claude-opus-4-7 --region eu-west-1
375
+ bin/smoke_model.rb --model eu.anthropic.claude-opus-4-8 --region eu-west-1
376
376
 
377
377
  # Subset of checks, e.g. when iterating on cache behavior
378
- bin/smoke_model.rb --model claude-sonnet-4-6 --checks cache
378
+ bin/smoke_model.rb --model claude-sonnet-5 --checks cache
379
379
 
380
380
  # Force a provider when the model ID is ambiguous
381
- bin/smoke_model.rb --provider anthropic --model claude-opus-4-7
381
+ bin/smoke_model.rb --provider anthropic --model claude-opus-4-8
382
382
  ```
383
383
 
384
384
  `DEBUG=1` enables the providers' raw request/response logging.
385
385
 
386
- If the model rejects a parameter the gem sends by default (e.g. opus-4-7 deprecated `temperature`), add the model ID to `Configuration::MODELS_WITHOUT_TEMPERATURE` in `lib/rails_console_ai/configuration.rb` so the providers omit the field.
386
+ Pricing, default max tokens, and parameter support (e.g. which families reject `temperature`) are keyed by model family in `Configuration::MODEL_FAMILIES` (`lib/rails_console_ai/configuration.rb`). Family matching is by substring, so one entry covers bare IDs, dated snapshots, and Bedrock inference profiles (`us.` / `global.` prefixes). When adopting a new model family, add an entry there and smoke-test it.
387
387
 
388
388
  ## Configuration
389
389
 
@@ -513,12 +513,17 @@ id = RailsConsoleAi.run_agent("How many users signed up yesterday?", name: 'dail
513
513
  # => 4821 (Integer session id, returned immediately)
514
514
 
515
515
  RailsConsoleAi.check_agent(id)
516
- # => 'queued' | 'running' | 'ready' | 'failed' | nil
516
+ # => 'queued' | 'running' | 'ready' | 'failed' | 'aborted' | nil
517
517
 
518
518
  RailsConsoleAi.get_agent_response(id)
519
519
  # => { status: 'ready', result: "1,432 users signed up yesterday.\n", error: nil }
520
+
521
+ RailsConsoleAi.abort_agent(id)
522
+ # => true (aborted) | false (already finished, or unknown id)
520
523
  ```
521
524
 
525
+ `abort_agent` cancels a run: a queued run is never picked up, and a run already executing keeps going but its result is discarded when it completes -- the session stays `status='aborted'`.
526
+
522
527
  `run_agent` enqueues a row in the sessions table with `mode='agent_api'` and `status='queued'`. A separate long-running rake task picks them up and runs each in its own thread using the same engine that powers `ai "..."` in the console.
523
528
 
524
529
  ### Per-run options
@@ -1,5 +1,5 @@
1
1
  module RailsConsoleAi
2
- class AgentVersionsController < ApplicationController
2
+ class AgentVersionsController < RailsConsoleAi::ApplicationController
3
3
  before_action :load_agent
4
4
 
5
5
  def index
@@ -1,7 +1,7 @@
1
1
  require 'rails_console_ai/agent_loader'
2
2
 
3
3
  module RailsConsoleAi
4
- class AgentsController < ApplicationController
4
+ class AgentsController < RailsConsoleAi::ApplicationController
5
5
  before_action :load_agent, only: [:show, :edit, :update, :destroy, :approve]
6
6
 
7
7
  def index
@@ -1,8 +1,8 @@
1
1
  require 'rails_console_ai/tools/memory_tools'
2
2
 
3
3
  module RailsConsoleAi
4
- class MemoriesController < ApplicationController
5
- before_action :load_memory, only: [:show, :edit, :update, :destroy]
4
+ class MemoriesController < RailsConsoleAi::ApplicationController
5
+ before_action :load_memory, only: [:show, :edit, :update, :destroy, :approve]
6
6
 
7
7
  def index
8
8
  @memories = Tools::MemoryTools.new.load_all_memories
@@ -73,6 +73,26 @@ module RailsConsoleAi
73
73
  end
74
74
  end
75
75
 
76
+ def approve
77
+ redirect_to memories_path, alert: file_memory_message and return unless @memory.is_a?(RailsConsoleAi::Memory)
78
+
79
+ approver = params[:approved_by].presence ||
80
+ (request.respond_to?(:remote_user) && request.remote_user.presence) ||
81
+ 'web'
82
+
83
+ if @memory.approved?
84
+ redirect_to memory_path(@memory), notice: 'Memory is already approved.'
85
+ return
86
+ end
87
+
88
+ begin
89
+ @memory.approve!(approved_by: approver)
90
+ redirect_to memory_path(@memory), notice: "Approved by #{approver}. The AI can now recall this memory."
91
+ rescue ArgumentError, ActiveRecord::RecordInvalid => e
92
+ redirect_to memory_path(@memory), alert: "Could not approve: #{e.message}"
93
+ end
94
+ end
95
+
76
96
  def diff
77
97
  @memory = Memory.find(params[:memory_id])
78
98
  @from = @memory.versions.find(params[:from])
@@ -1,5 +1,5 @@
1
1
  module RailsConsoleAi
2
- class MemoryVersionsController < ApplicationController
2
+ class MemoryVersionsController < RailsConsoleAi::ApplicationController
3
3
  before_action :load_memory
4
4
 
5
5
  def index
@@ -1,5 +1,5 @@
1
1
  module RailsConsoleAi
2
- class SessionsController < ApplicationController
2
+ class SessionsController < RailsConsoleAi::ApplicationController
3
3
  PER_PAGE = 50
4
4
 
5
5
  def index
@@ -1,5 +1,5 @@
1
1
  module RailsConsoleAi
2
- class SkillVersionsController < ApplicationController
2
+ class SkillVersionsController < RailsConsoleAi::ApplicationController
3
3
  before_action :load_skill
4
4
 
5
5
  def index
@@ -1,7 +1,7 @@
1
1
  require 'rails_console_ai/skill_loader'
2
2
 
3
3
  module RailsConsoleAi
4
- class SkillsController < ApplicationController
4
+ class SkillsController < RailsConsoleAi::ApplicationController
5
5
  before_action :load_skill, only: [:show, :edit, :update, :destroy, :approve]
6
6
 
7
7
  def index
@@ -4,6 +4,10 @@ module RailsConsoleAi
4
4
  class Memory < ActiveRecord::Base
5
5
  self.table_name = 'rails_console_ai_memories'
6
6
 
7
+ STATUS_PROPOSED = 'proposed'.freeze
8
+ STATUS_APPROVED = 'approved'.freeze
9
+ STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
10
+
7
11
  has_many :versions,
8
12
  -> { order(created_at: :desc) },
9
13
  class_name: 'RailsConsoleAi::MemoryVersion',
@@ -12,11 +16,14 @@ module RailsConsoleAi
12
16
 
13
17
  validates :content, presence: true
14
18
  validates :name, presence: true, uniqueness: { case_sensitive: false }
19
+ validates :status, inclusion: { in: STATUSES }
15
20
  validate :content_parses
16
21
 
17
22
  before_validation :sync_name_from_content
18
23
 
19
24
  scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
25
+ scope :approved, -> { where(status: STATUS_APPROVED) }
26
+ scope :proposed, -> { where(status: STATUS_PROPOSED) }
20
27
 
21
28
  def self.connection
22
29
  klass = RailsConsoleAi.configuration.connection_class
@@ -42,6 +49,9 @@ module RailsConsoleAi
42
49
  def description; parsed['description']; end
43
50
  def tags; Array(parsed['tags']); end
44
51
 
52
+ def proposed?; status.to_s == STATUS_PROPOSED; end
53
+ def approved?; status.to_s == STATUS_APPROVED; end
54
+
45
55
  def self.record_use!(id)
46
56
  where(id: id).update_all([
47
57
  'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
@@ -60,6 +70,9 @@ module RailsConsoleAi
60
70
  'description' => description,
61
71
  'tags' => tags,
62
72
  'content' => content,
73
+ 'status' => status,
74
+ 'approved_by' => approved_by,
75
+ 'approved_at' => approved_at,
63
76
  'use_count' => use_count,
64
77
  'last_used_at' => last_used_at,
65
78
  'source' => :db,
@@ -67,14 +80,25 @@ module RailsConsoleAi
67
80
  }
68
81
  end
69
82
 
70
- def update_with_version!(attrs, edited_by: nil, change_note: nil)
83
+ # Assigns attrs, saves, and records one MemoryVersion snapshot.
84
+ # Any change to `content` reverts approval back to "proposed" unless
85
+ # `preserve_approval: true` is passed (approve! does this).
86
+ def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
71
87
  transaction do
72
88
  assign_attributes(attrs)
89
+
90
+ if !preserve_approval && approved? && changes.key?('content')
91
+ self.status = STATUS_PROPOSED
92
+ self.approved_by = nil
93
+ self.approved_at = nil
94
+ end
95
+
73
96
  save!
74
97
  RailsConsoleAi::MemoryVersion.create!(
75
98
  memory_id: id,
76
99
  name: name,
77
100
  content: content,
101
+ status: status,
78
102
  edited_by: edited_by,
79
103
  change_note: change_note
80
104
  )
@@ -82,6 +106,21 @@ module RailsConsoleAi
82
106
  self
83
107
  end
84
108
 
109
+ def approve!(approved_by:)
110
+ raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
111
+
112
+ update_with_version!(
113
+ {
114
+ status: STATUS_APPROVED,
115
+ approved_by: approved_by,
116
+ approved_at: Time.now.utc
117
+ },
118
+ edited_by: approved_by,
119
+ change_note: "Approved by #{approved_by}",
120
+ preserve_approval: true
121
+ )
122
+ end
123
+
85
124
  private
86
125
 
87
126
  def sync_name_from_content
@@ -11,6 +11,13 @@
11
11
  </form>
12
12
  </div>
13
13
 
14
+ <% proposed_count = @memories.count { |m| m['source'] == :db && m['status'] != 'approved' } %>
15
+ <% if proposed_count > 0 %>
16
+ <div class="flash flash-alert" style="margin-bottom:16px;">
17
+ <strong><%= proposed_count %></strong> memor<%= proposed_count == 1 ? 'y' : 'ies' %> awaiting approval. The AI cannot recall them until a human approves.
18
+ </div>
19
+ <% end %>
20
+
14
21
  <% if @memories.empty? %>
15
22
  <div class="meta-card">
16
23
  <p class="text-muted">No memories yet. <%= link_to 'Create one', new_memory_path %> or drop a Markdown file in <code>.rails_console_ai/memories/</code>.</p>
@@ -22,7 +29,7 @@
22
29
  <th>Name</th>
23
30
  <th>Description</th>
24
31
  <th>Tags</th>
25
- <th>Source</th>
32
+ <th>Source / Status</th>
26
33
  <th title="Number of times the AI recalled this memory (DB only)">Uses</th>
27
34
  <th>Last used</th>
28
35
  <th></th>
@@ -40,6 +47,11 @@
40
47
  <td>
41
48
  <% if m['source'] == :db %>
42
49
  <span class="badge source-db">DB</span>
50
+ <% if m['status'] == 'approved' %>
51
+ <span class="badge status-approved">APPROVED</span>
52
+ <% else %>
53
+ <span class="badge status-proposed">PROPOSED</span>
54
+ <% end %>
43
55
  <% else %>
44
56
  <span class="badge source-file">FILE</span>
45
57
  <% end %>
@@ -8,11 +8,23 @@
8
8
 
9
9
  <%= link_to '← All memories', memories_path, class: 'back-link' %>
10
10
 
11
+ <% if is_db && !@memory.approved? %>
12
+ <div class="flash flash-alert" style="margin-bottom:16px;">
13
+ <strong>Awaiting approval.</strong> The AI cannot recall this memory until a human approves it.
14
+ Review the content below, then click <strong>Approve</strong>.
15
+ </div>
16
+ <% end %>
17
+
11
18
  <div class="meta-card">
12
19
  <h2 style="margin-bottom:8px;">
13
20
  <%= name %>
14
21
  <% if is_db %>
15
22
  <span class="badge source-db">DB</span>
23
+ <% if @memory.approved? %>
24
+ <span class="badge status-approved">APPROVED</span>
25
+ <% else %>
26
+ <span class="badge status-proposed">PROPOSED</span>
27
+ <% end %>
16
28
  <% else %>
17
29
  <span class="badge source-file">FILE</span>
18
30
  <% end %>
@@ -24,6 +36,10 @@
24
36
  <div class="meta-item"><label>Versions</label><span><%= @memory.versions.count %></span></div>
25
37
  <div class="meta-item"><label>Times recalled</label><span><%= @memory.use_count %></span></div>
26
38
  <div class="meta-item"><label>Last recalled</label><span><%= @memory.last_used_at&.strftime('%Y-%m-%d %H:%M') || 'never' %></span></div>
39
+ <% if @memory.approved? %>
40
+ <div class="meta-item"><label>Approved by</label><span><%= @memory.approved_by || '—' %></span></div>
41
+ <div class="meta-item"><label>Approved at</label><span><%= @memory.approved_at&.strftime('%Y-%m-%d %H:%M') || '—' %></span></div>
42
+ <% end %>
27
43
  <% else %>
28
44
  <div class="meta-item"><label>Path</label><span class="mono"><%= file_key %></span></div>
29
45
  <% end %>
@@ -32,6 +48,12 @@
32
48
 
33
49
  <div class="btn-bar">
34
50
  <% if is_db %>
51
+ <% unless @memory.approved? %>
52
+ <%= form_with url: approve_memory_path(@memory), method: :post, local: true, style: 'display:inline' do %>
53
+ <input type="text" name="approved_by" placeholder="Your name (required)" style="padding:7px 10px; border:1px solid #ccc; border-radius:4px; margin-right:6px;">
54
+ <button type="submit" class="btn btn-danger" data-confirm="Approve this memory? The AI will then be able to recall it.">Approve</button>
55
+ <% end %>
56
+ <% end %>
35
57
  <%= link_to 'Edit', edit_memory_path(@memory), class: 'btn' %>
36
58
  <%= link_to 'Versions', memory_versions_path(@memory), class: 'btn btn-secondary' %>
37
59
  <%= button_to 'Delete', memory_path(@memory), method: :delete, class: 'btn btn-danger', form: { style: 'display:inline' },data: { confirm: 'Delete this memory? Past versions remain in history.' } %>
@@ -49,6 +71,7 @@
49
71
  <div>
50
72
  <strong>v<%= v.id %></strong>
51
73
  <span class="ver-meta">— <%= v.created_at.strftime('%Y-%m-%d %H:%M') %> by <%= v.edited_by || 'unknown' %></span>
74
+ <% if v.status == 'approved' %><span class="badge status-approved">APPROVED</span><% end %>
52
75
  <% if v.change_note.present? %><div class="ver-meta"><%= v.change_note %></div><% end %>
53
76
  </div>
54
77
  <div>
data/config/routes.rb CHANGED
@@ -17,6 +17,9 @@ RailsConsoleAi::Engine.routes.draw do
17
17
  end
18
18
 
19
19
  resources :memories do
20
+ member do
21
+ post :approve
22
+ end
20
23
  collection do
21
24
  get :diff
22
25
  end
@@ -5,11 +5,11 @@ RailsConsoleAi.configure do |config|
5
5
  # API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
6
6
  # config.api_key = 'sk-...'
7
7
 
8
- # Model override (defaults: claude-opus-4-6 for Anthropic, gpt-5.3-codex for OpenAI)
9
- # config.model = 'claude-opus-4-6'
8
+ # Model override (defaults: claude-sonnet-5 for Anthropic, gpt-5.3-codex for OpenAI)
9
+ # config.model = 'claude-opus-4-8'
10
10
 
11
- # Max tokens for LLM response
12
- config.max_tokens = 4096
11
+ # Max tokens for LLM response (default resolves per model family; 16000 for Claude models)
12
+ # config.max_tokens = 4096
13
13
 
14
14
  # Temperature (0.0 - 1.0)
15
15
  config.temperature = 0.2
@@ -35,7 +35,7 @@ RailsConsoleAi.configure do |config|
35
35
  # AWS Bedrock provider (uses AWS credential chain — no API key needed):
36
36
  # config.provider = :bedrock
37
37
  # config.bedrock_region = 'us-east-1'
38
- # config.model = 'us.anthropic.claude-sonnet-4-6'
38
+ # config.model = 'us.anthropic.claude-sonnet-5'
39
39
 
40
40
  # Debug mode: prints full API requests/responses and tool calls to stderr
41
41
  # config.debug = true
@@ -134,6 +134,10 @@ module RailsConsoleAi
134
134
  end
135
135
  result_text = compose_result(channel.captured_output, exec_result)
136
136
 
137
+ if aborted?(session.id)
138
+ puts ">> aborted (result discarded)"
139
+ return
140
+ end
137
141
  SessionLogger.update(session.id, status: 'ready', result: result_text)
138
142
 
139
143
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
@@ -142,16 +146,23 @@ module RailsConsoleAi
142
146
  puts ">> ready (#{elapsed}ms) #{preview}"
143
147
  rescue RunnerTimeoutError => e
144
148
  warn ">> TIMEOUT #{e.message}"
145
- SessionLogger.update(session.id, status: 'failed', error_message: e.message)
149
+ SessionLogger.update(session.id, status: 'failed', error_message: e.message) unless aborted?(session.id)
146
150
  rescue => e
147
151
  warn ">> FAILED #{e.class}: #{e.message}"
148
152
  e.backtrace&.first(5)&.each { |line| warn " #{line}" }
153
+ return if aborted?(session.id)
149
154
  SessionLogger.update(session.id,
150
155
  status: 'failed',
151
156
  error_message: "#{e.class}: #{e.message}\n#{Array(e.backtrace).first(10).join("\n")}"
152
157
  )
153
158
  end
154
159
 
160
+ # An abort (RailsConsoleAi.abort_agent) flips the row to 'aborted' while we
161
+ # run; in that case the run's outcome must not overwrite the abort.
162
+ def aborted?(session_id)
163
+ Session.where(id: session_id).pluck(:status).first == 'aborted'
164
+ end
165
+
155
166
  # When cap is nil or non-positive, run inline. Otherwise spawn a nested
156
167
  # worker thread, join with the deadline, and kill + raise on overshoot.
157
168
  # Kept localized (vs Timeout.timeout) so a runaway provider call can't
@@ -368,7 +368,7 @@ module RailsConsoleAi
368
368
  config = RailsConsoleAi.configuration
369
369
  model = @engine.effective_model
370
370
  thinking = config.resolved_thinking_model
371
- pricing = Configuration::PRICING[model]
371
+ pricing = Configuration.pricing_for(model)
372
372
 
373
373
  @real_stdout.puts "\e[36m Model info:\e[0m"
374
374
  @real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"
@@ -1,35 +1,51 @@
1
- require 'set'
2
-
3
1
  module RailsConsoleAi
4
2
  class Configuration
5
3
  PROVIDERS = %i[anthropic openai local bedrock].freeze
6
4
 
7
- # cache_read: 0.1x input, cache_write: 1.25x input for Anthropic models
8
- PRICING = {
9
- 'claude-sonnet-4-6' => { input: 3.0 / 1_000_000, output: 15.0 / 1_000_000, cache_read: 0.30 / 1_000_000, cache_write: 3.75 / 1_000_000 },
10
- 'claude-opus-4-6' => { input: 15.0 / 1_000_000, output: 75.0 / 1_000_000, cache_read: 1.50 / 1_000_000, cache_write: 18.75 / 1_000_000 },
11
- 'claude-haiku-4-5-20251001' => { input: 0.80 / 1_000_000, output: 4.0 / 1_000_000, cache_read: 0.08 / 1_000_000, cache_write: 1.0 / 1_000_000 },
12
- # Bedrock model IDs (same pricing as direct API)
13
- 'us.anthropic.claude-sonnet-4-6' => { input: 3.0 / 1_000_000, output: 15.0 / 1_000_000, cache_read: 0.30 / 1_000_000, cache_write: 3.75 / 1_000_000 },
14
- 'us.anthropic.claude-opus-4-6-v1' => { input: 15.0 / 1_000_000, output: 75.0 / 1_000_000, cache_read: 1.50 / 1_000_000, cache_write: 18.75 / 1_000_000 },
5
+ # Per-family model attributes, matched by substring so one entry covers every
6
+ # ID variant of a family: bare Anthropic IDs (claude-sonnet-5), dated
7
+ # snapshots (claude-haiku-4-5-20251001), and Bedrock inference profiles
8
+ # (us.anthropic.claude-sonnet-5, global.anthropic.claude-opus-4-6-v1).
9
+ #
10
+ # input/output are $ per MTok (converted to per-token in .pricing_for).
11
+ # Cache pricing is derived: read = 0.1x input, write = 1.25x input.
12
+ # temperature: false marks families that reject the `temperature` parameter
13
+ # (removed on opus-4-7+, sonnet-5, and fable-5).
14
+ MODEL_FAMILIES = {
15
+ 'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, temperature: false },
16
+ 'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
17
+ 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
18
+ 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
19
+ 'claude-sonnet-5' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: false },
20
+ 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: true },
21
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, temperature: true },
15
22
  }.freeze
16
23
 
17
- DEFAULT_MAX_TOKENS = {
18
- 'claude-sonnet-4-6' => 16_000,
19
- 'claude-haiku-4-5-20251001' => 16_000,
20
- 'claude-opus-4-6' => 4_096,
21
- }.freeze
24
+ # Family keys sorted longest-first so a more specific family always wins
25
+ # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
26
+ # before 'claude-sonnet-5').
27
+ MODEL_FAMILY_KEYS = MODEL_FAMILIES.keys.sort_by { |k| -k.length }.freeze
22
28
 
23
- # Models that reject the `temperature` parameter. Configuration#resolved_temperature
24
- # returns nil for these so providers can omit the field from the request.
25
- MODELS_WITHOUT_TEMPERATURE = Set.new(%w[
26
- claude-opus-4-7
27
- anthropic.claude-opus-4-7
28
- us.anthropic.claude-opus-4-7
29
- eu.anthropic.claude-opus-4-7
30
- jp.anthropic.claude-opus-4-7
31
- global.anthropic.claude-opus-4-7
32
- ]).freeze
29
+ # Returns the family attributes for a model ID, or nil for unknown models.
30
+ def self.model_family(model_id)
31
+ return nil unless model_id
32
+ key = MODEL_FAMILY_KEYS.find { |k| model_id.include?(k) }
33
+ key && MODEL_FAMILIES[key]
34
+ end
35
+
36
+ # Per-token pricing for a model ID, matched by family. Returns
37
+ # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
38
+ def self.pricing_for(model_id)
39
+ family = model_family(model_id)
40
+ return nil unless family
41
+ input = family[:input] / 1_000_000
42
+ {
43
+ input: input,
44
+ output: family[:output] / 1_000_000,
45
+ cache_read: input * 0.1,
46
+ cache_write: input * 1.25,
47
+ }
48
+ end
33
49
 
34
50
  attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
35
51
  :auto_execute, :temperature,
@@ -176,26 +192,28 @@ module RailsConsoleAi
176
192
 
177
193
  case @provider
178
194
  when :anthropic
179
- 'claude-sonnet-4-6'
195
+ 'claude-sonnet-5'
180
196
  when :openai
181
197
  'gpt-5.3-codex'
182
198
  when :local
183
199
  @local_model
184
200
  when :bedrock
185
- 'us.anthropic.claude-sonnet-4-6'
201
+ 'us.anthropic.claude-sonnet-5'
186
202
  end
187
203
  end
188
204
 
189
205
  def resolved_max_tokens
190
206
  return @max_tokens if @max_tokens
191
207
 
192
- DEFAULT_MAX_TOKENS.fetch(resolved_model, 4096)
208
+ family = self.class.model_family(resolved_model)
209
+ family ? family[:max_tokens] : 4096
193
210
  end
194
211
 
195
- # Returns nil for models that reject the `temperature` parameter (e.g. opus-4-7).
196
- # Providers should use this in place of @temperature.
212
+ # Returns nil for model families that reject the `temperature` parameter
213
+ # (opus-4-7+, sonnet-5, fable-5) so providers omit the field from the request.
197
214
  def resolved_temperature
198
- return nil if MODELS_WITHOUT_TEMPERATURE.include?(resolved_model)
215
+ family = self.class.model_family(resolved_model)
216
+ return nil if family && family[:temperature] == false
199
217
  @temperature
200
218
  end
201
219
 
@@ -204,13 +222,13 @@ module RailsConsoleAi
204
222
 
205
223
  case @provider
206
224
  when :anthropic
207
- 'claude-opus-4-6'
225
+ 'claude-opus-4-8'
208
226
  when :openai
209
227
  'gpt-5.3-codex'
210
228
  when :local
211
229
  @local_model
212
230
  when :bedrock
213
- 'us.anthropic.claude-opus-4-6-v1'
231
+ 'us.anthropic.claude-opus-4-8'
214
232
  end
215
233
  end
216
234
 
@@ -390,7 +390,7 @@ module RailsConsoleAi
390
390
  $stdout.puts "\e[36m Cost estimate:\e[0m"
391
391
 
392
392
  @token_usage.each do |model, usage|
393
- pricing = Configuration::PRICING[model]
393
+ pricing = Configuration.pricing_for(model)
394
394
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
395
395
  input_str = "in: #{format_tokens(usage[:input])}"
396
396
  output_str = "out: #{format_tokens(usage[:output])}"
@@ -1386,7 +1386,7 @@ module RailsConsoleAi
1386
1386
  cache_w = result.cache_write_input_tokens || 0
1387
1387
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1388
1388
  model = effective_model
1389
- pricing = Configuration::PRICING[model]
1389
+ pricing = Configuration.pricing_for(model)
1390
1390
  if pricing
1391
1391
  cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1392
1392
  if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
@@ -1419,7 +1419,7 @@ module RailsConsoleAi
1419
1419
  input_t = result.input_tokens || 0
1420
1420
  output_t = result.output_tokens || 0
1421
1421
  model = effective_model
1422
- pricing = Configuration::PRICING[model]
1422
+ pricing = Configuration.pricing_for(model)
1423
1423
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1424
1424
 
1425
1425
  cache_r = result.cache_read_input_tokens || 0
@@ -52,6 +52,22 @@ module RailsConsoleAi
52
52
  Thread.current[:rails_console_ai_guards_disabled] = true
53
53
  end
54
54
 
55
+ # Hard sandbox: block ALL database access (reads and writes) for the duration of the
56
+ # block. Used to fence in sub-agents that must never touch the DB (e.g. the
57
+ # output-explorer, which only examines an in-memory string). Scoped via thread-local,
58
+ # so it covers synchronous work in the calling thread and is restored afterward —
59
+ # nestable, and independent of enable!/disable! and the registered guard set.
60
+ def with_database_blocked
61
+ BuiltinGuards.ensure_write_blocker_installed!
62
+ prev = Thread.current[:rails_console_ai_block_all_db]
63
+ Thread.current[:rails_console_ai_block_all_db] = true
64
+ begin
65
+ yield
66
+ ensure
67
+ Thread.current[:rails_console_ai_block_all_db] = prev
68
+ end
69
+ end
70
+
55
71
  def empty?
56
72
  @guards.empty?
57
73
  end
@@ -213,6 +229,21 @@ module RailsConsoleAi
213
229
 
214
230
  private
215
231
 
232
+ # Hard sandbox: when active, block ALL database access (reads and writes), not just
233
+ # mutations. Used to fence in sub-agents that must never touch the DB (e.g. the
234
+ # output-explorer, which only examines an in-memory string). Deliberately does NOT
235
+ # honor the bypass flag — it is a true wall, not a safe-mode toggle.
236
+ def rails_console_ai_check_db_blocked!(_sql)
237
+ return unless Thread.current[:rails_console_ai_block_all_db]
238
+
239
+ raise RailsConsoleAi::SafetyError.new(
240
+ "Database access is disabled here. The captured data is in the `output` " \
241
+ "variable — examine that instead of querying the database.",
242
+ guard: :database_access,
243
+ blocked_key: nil
244
+ )
245
+ end
246
+
216
247
  def rails_console_ai_check_write!(sql)
217
248
  return if Thread.current[:rails_console_ai_bypass_guards]
218
249
  return unless Thread.current[:rails_console_ai_block_writes] && sql.match?(WRITE_PATTERN)
@@ -231,26 +262,31 @@ module RailsConsoleAi
231
262
  public
232
263
 
233
264
  def execute(sql, *args, **kwargs)
265
+ rails_console_ai_check_db_blocked!(sql)
234
266
  rails_console_ai_check_write!(sql)
235
267
  super
236
268
  end
237
269
 
238
270
  def exec_query(sql, *args, **kwargs)
271
+ rails_console_ai_check_db_blocked!(sql)
239
272
  rails_console_ai_check_write!(sql)
240
273
  super
241
274
  end
242
275
 
243
276
  def exec_insert(sql, *args, **kwargs)
277
+ rails_console_ai_check_db_blocked!(sql)
244
278
  rails_console_ai_check_write!(sql)
245
279
  super
246
280
  end
247
281
 
248
282
  def exec_delete(sql, *args, **kwargs)
283
+ rails_console_ai_check_db_blocked!(sql)
249
284
  rails_console_ai_check_write!(sql)
250
285
  super
251
286
  end
252
287
 
253
288
  def exec_update(sql, *args, **kwargs)
289
+ rails_console_ai_check_db_blocked!(sql)
254
290
  rails_console_ai_check_write!(sql)
255
291
  super
256
292
  end
@@ -706,7 +706,7 @@ module RailsConsoleAi
706
706
  config = RailsConsoleAi.configuration
707
707
  model = engine ? engine.effective_model : config.resolved_model
708
708
  thinking = config.resolved_thinking_model
709
- pricing = Configuration::PRICING[model]
709
+ pricing = Configuration.pricing_for(model)
710
710
 
711
711
  lines = ["*Model info:*"]
712
712
  lines << " Provider: `#{config.provider}`"
@@ -739,7 +739,7 @@ module RailsConsoleAi
739
739
  total_cost = 0.0
740
740
 
741
741
  token_usage.each do |model, usage|
742
- pricing = Configuration::PRICING[model]
742
+ pricing = Configuration.pricing_for(model)
743
743
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
744
744
  input_str = "in: #{usage[:input]}"
745
745
  output_str = "out: #{usage[:output]}"
@@ -189,7 +189,7 @@ module RailsConsoleAi
189
189
 
190
190
  def build_system_prompt
191
191
  parts = []
192
- parts << base_instructions
192
+ parts << base_instructions unless @agent_config['skip_base_instructions']
193
193
  parts << guide_context
194
194
  parts << pinned_memory_context
195
195
  parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
@@ -33,10 +33,15 @@ module RailsConsoleAi
33
33
  name: name, description: description, tags: tags,
34
34
  edited_by: edited_by || 'ai', change_note: change_note
35
35
  )
36
+ status_note = if record.respond_to?(:proposed?) && record.proposed?
37
+ ' — status: PROPOSED. A human must approve it at /rails_console_ai/memories before you can recall it.'
38
+ else
39
+ ''
40
+ end
36
41
  if was_new
37
- "Memory saved (db): \"#{record.name}\" (id=#{record.id})"
42
+ "Memory saved (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
38
43
  else
39
- "Memory updated (db): \"#{record.name}\" (id=#{record.id})"
44
+ "Memory updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
40
45
  end
41
46
  end
42
47
  rescue Storage::StorageError => e
@@ -72,8 +77,16 @@ module RailsConsoleAi
72
77
  end
73
78
 
74
79
  def recall_memory(name:)
75
- memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
76
- return "No memory found: \"#{name}\"" unless memory
80
+ memory = load_activatable_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
81
+ unless memory
82
+ # Distinguish "doesn't exist" from "exists but isn't approved yet".
83
+ proposed = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
84
+ if proposed && proposed['source'] == :db && proposed['status'] != 'approved'
85
+ return "Memory \"#{name}\" exists but is awaiting human approval and cannot be recalled yet. " \
86
+ "Ask the user to approve it in the web UI at /rails_console_ai/memories."
87
+ end
88
+ return "No memory found: \"#{name}\""
89
+ end
77
90
 
78
91
  record_use(memory)
79
92
 
@@ -83,7 +96,7 @@ module RailsConsoleAi
83
96
  end
84
97
 
85
98
  def recall_memories(query: nil, tag: nil)
86
- memories = load_all_memories
99
+ memories = load_activatable_memories
87
100
  return "No memories stored yet." if memories.empty?
88
101
 
89
102
  results = memories
@@ -117,7 +130,7 @@ module RailsConsoleAi
117
130
  end
118
131
 
119
132
  def memory_summaries
120
- memories = load_all_memories
133
+ memories = load_activatable_memories
121
134
  return nil if memories.empty?
122
135
 
123
136
  memories.map { |m|
@@ -127,6 +140,10 @@ module RailsConsoleAi
127
140
  }
128
141
  end
129
142
 
143
+ # Includes proposed (unapproved) DB memories — they show up in the admin UI
144
+ # with a "PROPOSED" badge. The AI-facing surface (#memory_summaries,
145
+ # #recall_memory, #recall_memories) filters them out via
146
+ # #load_activatable_memories, so an unapproved memory can never be recalled.
130
147
  def load_all_memories
131
148
  db = Storage::DatabaseStorage.all_memories
132
149
  file = load_all_file_memories
@@ -135,6 +152,12 @@ module RailsConsoleAi
135
152
  (db + file).sort_by { |m| m['name'].to_s.downcase }
136
153
  end
137
154
 
155
+ # Memories the AI is allowed to see / recall: approved DB memories + all file
156
+ # memories. File memories are considered pre-approved because they're git-tracked.
157
+ def load_activatable_memories
158
+ load_all_memories.reject { |m| m['source'] == :db && m['status'] != 'approved' }
159
+ end
160
+
138
161
  private
139
162
 
140
163
  # DB-backed memories only — file memories have no row to update.
@@ -338,19 +338,27 @@ module RailsConsoleAi
338
338
  'name' => 'output-explorer',
339
339
  'tools' => ['execute_code'],
340
340
  'max_rounds' => 8,
341
+ 'skip_base_instructions' => true,
341
342
  'body' => <<~PROMPT.freeze
342
- You are exploring a single chunk of captured tool output on behalf of the main assistant.
343
+ You are exploring a single chunk of already-captured tool output on behalf of the
344
+ main assistant. Your ONLY job is to answer a question about that captured text.
343
345
 
344
346
  The full output is bound to the local variable `output` (a String). You do NOT see it
345
- directly — it lives in Ruby memory. Use `execute_code` with Ruby to query it:
347
+ directly — it lives in Ruby memory. Use `execute_code` with Ruby to examine it:
346
348
  - `output.length`, `output.lines.count`
347
349
  - `output[start, len]`, `output.lines[n]`
348
350
  - `output.scan(/pattern/)`, `output.include?("...")`
349
351
  - `JSON.parse(output)` if it looks like JSON, then drill in
350
352
  - any other Ruby string/collection methods
351
353
 
352
- Print only the specific slice or summary the task requires never dump the whole `output`.
353
- Return a concise factual answer. No preamble.
354
+ Database / ActiveRecord access is DISABLED in this contextany DB query (e.g.
355
+ `SomeModel.where(...)`, `.find`, raw SQL) will be blocked and raise an error. The data
356
+ you need exists ONLY in the `output` String already in memory. Do not load models, do
357
+ not run queries, and do not try to "look up" anything — every fact must come from
358
+ examining `output`.
359
+
360
+ Print only the specific slice or summary the task requires — never dump the whole
361
+ `output`. Return a concise factual answer. No preamble.
354
362
  PROMPT
355
363
  }.freeze
356
364
 
@@ -368,7 +376,11 @@ module RailsConsoleAi
368
376
  executor: @executor,
369
377
  output_payload: payload.dup
370
378
  )
371
- result = sub.run
379
+ # Hard sandbox: the output-explorer only examines the in-memory `output` string.
380
+ # Block all DB access for the duration of its run so a stray query can't escape to
381
+ # the live database. Scoped via thread-local (explore_output runs synchronously),
382
+ # so the parent session's guard state is untouched.
383
+ result = RailsConsoleAi.configuration.safety_guards.with_database_blocked { sub.run }
372
384
  @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used }
373
385
  "Exploration result (#{sub.input_tokens + sub.output_tokens} tokens used, #{payload.length} chars explored):\n#{result}"
374
386
  end
@@ -418,7 +430,7 @@ module RailsConsoleAi
418
430
 
419
431
  register(
420
432
  name: 'save_memory',
421
- description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic). Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/memories directory instead.',
433
+ description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic). Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/memories directory instead. IMPORTANT: memories saved to the DB start in "proposed" state and must be approved by a human in the web UI before you can recall them. Edits to an approved memory also revert it to proposed. Tell the user to visit /rails_console_ai/memories to approve.',
422
434
  parameters: {
423
435
  'type' => 'object',
424
436
  'properties' => {
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.31.0'.freeze
2
+ VERSION = '0.32.0'.freeze
3
3
  end
@@ -1,3 +1,5 @@
1
+ require 'set'
2
+
1
3
  require 'rails_console_ai/version'
2
4
  require 'rails_console_ai/configuration'
3
5
 
@@ -84,7 +86,7 @@ module RailsConsoleAi
84
86
 
85
87
  # Returns the current status string for an enqueued agent run, or nil
86
88
  # if the session id is not found. Status is one of:
87
- # 'queued' | 'running' | 'ready' | 'failed'.
89
+ # 'queued' | 'running' | 'ready' | 'failed' | 'aborted'.
88
90
  def check_agent(session_id)
89
91
  Session.where(id: session_id).pluck(:status).first
90
92
  end
@@ -98,6 +100,16 @@ module RailsConsoleAi
98
100
  { status: row.status, result: row.result, error: row.error_message }
99
101
  end
100
102
 
103
+ # Abort a queued or running agent run. Returns true if the run was
104
+ # aborted, false if it had already finished (or doesn't exist).
105
+ # Queued runs are never picked up; a run already executing keeps
106
+ # going but its result is discarded when it completes.
107
+ def abort_agent(session_id)
108
+ n = Session.where(id: session_id, status: %w[queued running])
109
+ .update_all(status: 'aborted', error_message: 'Aborted')
110
+ n == 1
111
+ end
112
+
101
113
  def status
102
114
  c = configuration
103
115
  key = c.resolved_api_key
@@ -242,20 +254,41 @@ module RailsConsoleAi
242
254
  conn.create_table(memories_table) do |t|
243
255
  t.string :name, limit: 255, null: false
244
256
  t.text :content, null: false
257
+ t.string :status, limit: 20, default: 'proposed', null: false
258
+ t.string :approved_by, limit: 255
259
+ t.datetime :approved_at
245
260
  t.integer :use_count, default: 0, null: false
246
261
  t.datetime :last_used_at
247
262
  t.datetime :created_at, null: false
248
263
  t.datetime :updated_at, null: false
249
264
  end
250
265
  conn.add_index(memories_table, :name, unique: true)
266
+ conn.add_index(memories_table, :status)
251
267
  $stdout.puts "\e[32mRailsConsoleAi: created #{memories_table} table.\e[0m"
252
268
  end
253
269
 
270
+ # Existing installs have the content-based memories table but predate the
271
+ # approval columns — the drop-on-missing-content guard above won't fire for
272
+ # them, so add the columns in place. Memories created before approval existed
273
+ # were trusted under the old no-approval regime, so grandfather them to
274
+ # "approved" rather than yanking them out from under the AI; only memories
275
+ # created from now on start in "proposed".
276
+ if conn.table_exists?(memories_table) && !conn.column_exists?(memories_table, :status)
277
+ conn.add_column(memories_table, :status, :string, limit: 20, default: 'proposed', null: false)
278
+ conn.execute("UPDATE #{conn.quote_table_name(memories_table)} SET status = 'approved'")
279
+ end
280
+ if conn.table_exists?(memories_table)
281
+ conn.add_column(memories_table, :approved_by, :string, limit: 255) unless conn.column_exists?(memories_table, :approved_by)
282
+ conn.add_column(memories_table, :approved_at, :datetime) unless conn.column_exists?(memories_table, :approved_at)
283
+ conn.add_index(memories_table, :status) unless conn.index_exists?(memories_table, :status)
284
+ end
285
+
254
286
  unless conn.table_exists?(versions_table)
255
287
  conn.create_table(versions_table) do |t|
256
288
  t.integer :memory_id
257
289
  t.string :name, limit: 255
258
290
  t.text :content
291
+ t.string :status, limit: 20
259
292
  t.string :edited_by, limit: 255
260
293
  t.text :change_note
261
294
  t.datetime :created_at, null: false
@@ -264,6 +297,10 @@ module RailsConsoleAi
264
297
  conn.add_index(versions_table, :created_at)
265
298
  $stdout.puts "\e[32mRailsConsoleAi: created #{versions_table} table.\e[0m"
266
299
  end
300
+
301
+ if conn.table_exists?(versions_table) && !conn.column_exists?(versions_table, :status)
302
+ conn.add_column(versions_table, :status, :string, limit: 20)
303
+ end
267
304
  end
268
305
 
269
306
  def setup_agents_tables!(conn)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr