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 +4 -4
- data/CHANGELOG.md +8 -26
- data/README.md +15 -10
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/agents_controller.rb +1 -1
- data/app/controllers/rails_console_ai/memories_controller.rb +22 -2
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/skills_controller.rb +1 -1
- data/app/models/rails_console_ai/memory.rb +40 -1
- data/app/views/rails_console_ai/memories/index.html.erb +13 -1
- data/app/views/rails_console_ai/memories/show.html.erb +23 -0
- data/config/routes.rb +3 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
- data/lib/rails_console_ai/agent_runner.rb +12 -1
- data/lib/rails_console_ai/channel/console.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +51 -33
- data/lib/rails_console_ai/conversation_engine.rb +3 -3
- data/lib/rails_console_ai/safety_guards.rb +36 -0
- data/lib/rails_console_ai/slack_bot.rb +2 -2
- data/lib/rails_console_ai/sub_agent.rb +1 -1
- data/lib/rails_console_ai/tools/memory_tools.rb +29 -6
- data/lib/rails_console_ai/tools/registry.rb +18 -6
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +38 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7eab1fd8d3a5c8c7a228206f0c723bfa845fa2d344471e0251659658c0c5e571
|
|
4
|
+
data.tar.gz: c607b6cfa9e6834612513f0c75dc1303386f79bcdd96bba203a4fb5d0549f32b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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-
|
|
321
|
-
# config.thinking_model = 'us.anthropic.claude-opus-4-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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,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,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
|
-
|
|
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
|
@@ -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-
|
|
9
|
-
# config.model = 'claude-opus-4-
|
|
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-
|
|
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
|
|
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
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
208
|
+
family = self.class.model_family(resolved_model)
|
|
209
|
+
family ? family[:max_tokens] : 4096
|
|
193
210
|
end
|
|
194
211
|
|
|
195
|
-
# Returns nil for
|
|
196
|
-
#
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
76
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
353
|
-
|
|
354
|
+
Database / ActiveRecord access is DISABLED in this context — any 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
|
-
|
|
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' => {
|
data/lib/rails_console_ai.rb
CHANGED
|
@@ -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)
|