rails_console_ai 0.29.0 → 0.30.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 +40 -0
- data/README.md +48 -0
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
- data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
- data/app/controllers/rails_console_ai/application_controller.rb +5 -0
- data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
- data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
- data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
- data/app/models/rails_console_ai/agent.rb +175 -0
- data/app/models/rails_console_ai/agent_version.rb +46 -0
- data/app/models/rails_console_ai/memory.rb +98 -0
- data/app/models/rails_console_ai/memory_version.rb +46 -0
- data/app/models/rails_console_ai/session.rb +1 -1
- data/app/models/rails_console_ai/skill.rb +198 -0
- data/app/models/rails_console_ai/skill_version.rb +54 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
- data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
- data/app/views/rails_console_ai/agents/_form.html.erb +65 -0
- data/app/views/rails_console_ai/agents/diff.html.erb +19 -0
- data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
- data/app/views/rails_console_ai/agents/index.html.erb +80 -0
- data/app/views/rails_console_ai/agents/new.html.erb +24 -0
- data/app/views/rails_console_ai/agents/show.html.erb +108 -0
- data/app/views/rails_console_ai/memories/_form.html.erb +36 -0
- data/app/views/rails_console_ai/memories/diff.html.erb +19 -0
- data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
- data/app/views/rails_console_ai/memories/index.html.erb +67 -0
- data/app/views/rails_console_ai/memories/new.html.erb +23 -0
- data/app/views/rails_console_ai/memories/show.html.erb +65 -0
- data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
- data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
- data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
- data/app/views/rails_console_ai/skills/_form.html.erb +65 -0
- data/app/views/rails_console_ai/skills/diff.html.erb +22 -0
- data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
- data/app/views/rails_console_ai/skills/index.html.erb +79 -0
- data/app/views/rails_console_ai/skills/new.html.erb +25 -0
- data/app/views/rails_console_ai/skills/show.html.erb +94 -0
- data/config/routes.rb +42 -0
- data/lib/rails_console_ai/agent_loader.rb +131 -43
- data/lib/rails_console_ai/agent_runner.rb +158 -0
- data/lib/rails_console_ai/channel/api.rb +139 -0
- data/lib/rails_console_ai/conversation_engine.rb +19 -13
- data/lib/rails_console_ai/session_logger.rb +6 -0
- data/lib/rails_console_ai/skill_loader.rb +119 -27
- data/lib/rails_console_ai/storage/database_storage.rb +201 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
- data/lib/rails_console_ai/tools/registry.rb +99 -8
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +256 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +55 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7455c7e1d55abd75c69bfa99621f1f83ec53a5faa2313e3095b3d80b88ec9938
|
|
4
|
+
data.tar.gz: 0a12cdef783d5f101d0c45b7fce9876e54bf31b1874fc4178a5d2103064ecb0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7433c944553f7c683b61e58653e76138e389e41ff29dff45c28fb344913a50514d2a58783fca2c916ff6f86a46a4d71cbad47803625e8078aa25895a2b12a057
|
|
7
|
+
data.tar.gz: 6cb61503f513cf4864fd918e95719f167733fc94ef001bb61290f6e0b262be0b2a3d83a936904ea7100096103c6c5c8145f74f5e99536088e45ad3aeb90d5a99
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.30.0]
|
|
6
|
+
|
|
7
|
+
- Add background agent support — new `run_agent` mechanism to launch agents asynchronously alongside an HTTP API channel
|
|
8
|
+
- Store skills and memories in the database with versioning, side-by-side diff, and restore, in addition to on-disk files
|
|
9
|
+
- Store sub-agents in the database with the same versioned workflow as skills
|
|
10
|
+
- Require human approval before the AI can use DB-backed skills or sub-agents; editing reverts them to proposed
|
|
11
|
+
- Track per-record usage (`use_count`, `last_used_at`) for DB-backed skills, memories, and sub-agents, surfaced in the web UI
|
|
12
|
+
- Add web UI sections for skills, memories, and agents with list / view / create / edit / delete / approve / version history
|
|
13
|
+
- Add `save_agent`, `delete_agent` AI tools and a `target` parameter on `save_skill` / `save_memory` for DB vs file
|
|
14
|
+
- Add a "Paste a .md file" import box on new skill / memory / agent pages that prefills the form from frontmatter
|
|
15
|
+
- Make `RailsConsoleAi.migrate!` re-run column probes on upgrades so new columns are added on existing installs
|
|
16
|
+
- Harden `Skill` / `Agent` model accessors to return safe defaults when newly added columns are not yet present
|
|
17
|
+
- Fix built-in agent `.md` files with UTF-8 characters failing to load under US-ASCII locales
|
|
18
|
+
|
|
19
|
+
## [Unreleased]
|
|
20
|
+
|
|
21
|
+
- 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
|
|
22
|
+
- `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
|
|
23
|
+
|
|
24
|
+
- 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:
|
|
25
|
+
- `activate_skill` resolves a DB skill
|
|
26
|
+
- `recall_memory` resolves a DB memory, or `recall_memories` returns a DB memory in its result set
|
|
27
|
+
- `delegate_task` resolves a DB sub-agent
|
|
28
|
+
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)
|
|
29
|
+
- 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
|
|
30
|
+
- 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
|
|
31
|
+
|
|
32
|
+
- 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
|
|
33
|
+
- 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
|
|
34
|
+
- 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
|
|
35
|
+
- `ai_db_setup` / `ai_db_migrate` now also create `rails_console_ai_agents` and `rails_console_ai_agent_versions` tables idempotently
|
|
36
|
+
- 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
|
|
37
|
+
|
|
38
|
+
- 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
|
|
39
|
+
- 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
|
|
40
|
+
- `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
|
|
41
|
+
- `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
|
|
42
|
+
- 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
|
|
43
|
+
- `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
|
|
44
|
+
|
|
5
45
|
## [0.29.0]
|
|
6
46
|
|
|
7
47
|
- 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
|
@@ -504,6 +504,54 @@ This starts a long-running process (run it separately from your web server). The
|
|
|
504
504
|
- **Channels** — the bot only responds when @mentioned. @mention it in any channel message or thread to start a session. The person who first @mentions the bot owns the session — only they can continue the conversation, and they must @mention the bot on each message. Exception: when the bot asks a question, the owner can reply without @mentioning.
|
|
505
505
|
- **Joining threads** — when @mentioned mid-thread, the bot reads the thread history for context so it understands what's already been discussed.
|
|
506
506
|
|
|
507
|
+
## Background Agents
|
|
508
|
+
|
|
509
|
+
Fire off agent runs from your application code and pick up the result later. Useful when you want to delegate a question (or a multi-step task) to the AI from a controller action, a job, a webhook handler, or any place where blocking on an LLM round-trip is undesirable.
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
id = RailsConsoleAi.run_agent("How many users signed up yesterday?", name: 'daily-stats', user_name: 'cron')
|
|
513
|
+
# => 4821 (Integer session id, returned immediately)
|
|
514
|
+
|
|
515
|
+
RailsConsoleAi.check_agent(id)
|
|
516
|
+
# => 'queued' | 'running' | 'ready' | 'failed' | nil
|
|
517
|
+
|
|
518
|
+
RailsConsoleAi.get_agent_response(id)
|
|
519
|
+
# => { status: 'ready', result: "1,432 users signed up yesterday.\n", error: nil }
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
`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
|
+
|
|
524
|
+
### Running the background runner
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
bundle exec rake rails_console_ai:agents
|
|
528
|
+
# AGENT_CONCURRENCY=3 by default; bump it for more parallelism
|
|
529
|
+
AGENT_CONCURRENCY=8 bundle exec rake rails_console_ai:agents
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
The background runner polls every 2 seconds, claims queued rows atomically (only one worker wins a row), and updates each row to `status='ready'` with the result, or `status='failed'` with an error message. SIGINT/SIGTERM triggers a graceful drain — up to 60 seconds for in-flight jobs, then any stragglers are marked `failed`.
|
|
533
|
+
|
|
534
|
+
Run it separately from your web server, alongside (or instead of) the Slack bot. If the process crashes mid-job, rows stuck in `status='running'` are left as-is — no built-in reaper yet.
|
|
535
|
+
|
|
536
|
+
### Requirements
|
|
537
|
+
|
|
538
|
+
- `RailsConsoleAi.setup!` must have been run so the sessions table has the `status`, `result`, and `error_message` columns. `ai_db_setup` (or `ai_db_migrate` on existing installs) handles this.
|
|
539
|
+
- `session_logging` must be enabled (it is by default).
|
|
540
|
+
|
|
541
|
+
### Behavior notes
|
|
542
|
+
|
|
543
|
+
- The agent runs with `Channel::Api`, a non-interactive channel — `prompt` returns `''` and `confirm` auto-yes, so the agent never blocks waiting for human input. Safety guards are always on (`supports_danger?` is `false`), and `bypass_guards_for_methods` still applies as usual.
|
|
544
|
+
- `result` is composed from the agent's prose answer plus the inspected return value of any code it executed:
|
|
545
|
+
|
|
546
|
+
```
|
|
547
|
+
Let me query the users table for signups from yesterday.
|
|
548
|
+
|
|
549
|
+
Result: 1432
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
If the agent answers without running code, you get just the prose. The raw code, its stdout, and the raw return value also live on the session row as `code_executed` / `code_output` / `code_result` if you need them.
|
|
553
|
+
- The queue row and the result row are the same row, so the existing session viewer at `/rails_console_ai` shows background runs alongside REPL and Slack sessions.
|
|
554
|
+
|
|
507
555
|
## Requirements
|
|
508
556
|
|
|
509
557
|
Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0. For Bedrock: `aws-sdk-bedrockruntime` (loaded lazily, not a hard dependency).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
class AgentVersionsController < ApplicationController
|
|
3
|
+
before_action :load_agent
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@versions = @agent.versions
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@version = @agent.versions.find(params[:id])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def restore
|
|
14
|
+
version = @agent.versions.find(params[:id])
|
|
15
|
+
@agent.update_with_version!(
|
|
16
|
+
{
|
|
17
|
+
name: version.name,
|
|
18
|
+
description: version.description,
|
|
19
|
+
body: version.body,
|
|
20
|
+
max_rounds: version.max_rounds,
|
|
21
|
+
model: version.model,
|
|
22
|
+
tools: Array(version.tools)
|
|
23
|
+
},
|
|
24
|
+
edited_by: params[:edited_by].presence || 'web',
|
|
25
|
+
change_note: "Restored from version ##{version.id}"
|
|
26
|
+
)
|
|
27
|
+
redirect_to agent_path(@agent), notice: "Restored version ##{version.id}."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def load_agent
|
|
33
|
+
@agent = Agent.find(params[:agent_id])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
require 'rails_console_ai/agent_loader'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class AgentsController < ApplicationController
|
|
5
|
+
before_action :load_agent, only: [:show, :edit, :update, :destroy, :approve]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@agents = AgentLoader.new.load_all_agents
|
|
9
|
+
@q = params[:q].to_s.strip
|
|
10
|
+
unless @q.empty?
|
|
11
|
+
needle = @q.downcase
|
|
12
|
+
@agents = @agents.select { |a|
|
|
13
|
+
[a['name'], a['description'], Array(a['tools']).join(' ')].compact.join(' ').downcase.include?(needle)
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@sort = params[:sort].to_s
|
|
18
|
+
if @sort == 'used'
|
|
19
|
+
@agents = @agents.sort_by { |a| [-(a['use_count'].to_i), a['name'].to_s.downcase] }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
@versions = @agent.versions if @agent.is_a?(RailsConsoleAi::Agent)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def new
|
|
28
|
+
# Allow prefilling from a built-in (the "Create override" action on the show page).
|
|
29
|
+
@agent = Agent.new
|
|
30
|
+
if params[:from_builtin].present?
|
|
31
|
+
builtin = AgentLoader.new.load_all_agents.find { |a|
|
|
32
|
+
a['source'] == :builtin && a['name'].to_s.downcase == params[:from_builtin].to_s.downcase
|
|
33
|
+
}
|
|
34
|
+
if builtin
|
|
35
|
+
@agent.name = builtin['name']
|
|
36
|
+
@agent.description = builtin['description']
|
|
37
|
+
@agent.body = builtin['body']
|
|
38
|
+
@agent.max_rounds = builtin['max_rounds']
|
|
39
|
+
@agent.model = builtin['model']
|
|
40
|
+
@agent.tools = Array(builtin['tools'])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create
|
|
46
|
+
@agent = Agent.new
|
|
47
|
+
begin
|
|
48
|
+
@agent.update_with_version!(
|
|
49
|
+
agent_params,
|
|
50
|
+
edited_by: edited_by_param,
|
|
51
|
+
change_note: params[:change_note].presence
|
|
52
|
+
)
|
|
53
|
+
redirect_to agent_path(@agent), notice: 'Agent created.'
|
|
54
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
55
|
+
flash.now[:alert] = e.message
|
|
56
|
+
render :new
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# POST /agents/import — parse a pasted .md blob and re-render `new` with fields prefilled.
|
|
61
|
+
def import
|
|
62
|
+
content = params[:content].to_s
|
|
63
|
+
if content.strip.empty?
|
|
64
|
+
redirect_to new_agent_path, alert: 'Nothing to parse — paste the .md content into the box first.'
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
parsed = AgentLoader.parse(content)
|
|
69
|
+
if parsed.nil? || parsed['name'].to_s.strip.empty?
|
|
70
|
+
redirect_to new_agent_path,
|
|
71
|
+
alert: 'Could not parse. Expected YAML frontmatter (between `---` lines) with at least a `name` field, followed by the agent body.'
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@agent = Agent.new(
|
|
76
|
+
name: parsed['name'],
|
|
77
|
+
description: parsed['description'],
|
|
78
|
+
body: parsed['body'],
|
|
79
|
+
max_rounds: parsed['max_rounds'],
|
|
80
|
+
model: parsed['model']
|
|
81
|
+
)
|
|
82
|
+
@agent.tools = Array(parsed['tools'])
|
|
83
|
+
|
|
84
|
+
flash.now[:notice] = "Parsed \"#{parsed['name']}\" from pasted content. Review the fields below and click Create agent to save to the DB."
|
|
85
|
+
render :new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def edit
|
|
89
|
+
redirect_to agents_path, alert: read_only_message and return unless @agent.is_a?(RailsConsoleAi::Agent)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def update
|
|
93
|
+
redirect_to agents_path, alert: read_only_message and return unless @agent.is_a?(RailsConsoleAi::Agent)
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
@agent.update_with_version!(
|
|
97
|
+
agent_params,
|
|
98
|
+
edited_by: edited_by_param,
|
|
99
|
+
change_note: params[:change_note].presence
|
|
100
|
+
)
|
|
101
|
+
redirect_to agent_path(@agent), notice: 'Agent updated.'
|
|
102
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
103
|
+
flash.now[:alert] = e.message
|
|
104
|
+
render :edit
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def destroy
|
|
109
|
+
if @agent.is_a?(RailsConsoleAi::Agent)
|
|
110
|
+
@agent.destroy
|
|
111
|
+
redirect_to agents_path, notice: 'Agent deleted. Past versions remain in history.'
|
|
112
|
+
else
|
|
113
|
+
redirect_to agents_path, alert: read_only_message
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def approve
|
|
118
|
+
redirect_to agents_path, alert: read_only_message and return unless @agent.is_a?(RailsConsoleAi::Agent)
|
|
119
|
+
|
|
120
|
+
approver = params[:approved_by].presence || 'web'
|
|
121
|
+
|
|
122
|
+
if @agent.approved?
|
|
123
|
+
redirect_to agent_path(@agent), notice: 'Agent is already approved.'
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
@agent.approve!(approved_by: approver)
|
|
129
|
+
redirect_to agent_path(@agent), notice: "Approved by #{approver}. The AI can now invoke this agent via delegate_task."
|
|
130
|
+
rescue ArgumentError, ActiveRecord::RecordInvalid => e
|
|
131
|
+
redirect_to agent_path(@agent), alert: "Could not approve: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def diff
|
|
136
|
+
@agent = Agent.find(params[:agent_id])
|
|
137
|
+
@from = @agent.versions.find(params[:from])
|
|
138
|
+
@to = params[:to].present? ? @agent.versions.find(params[:to]) : nil
|
|
139
|
+
@to_label = @to ? "Version ##{@to.id}" : 'Current'
|
|
140
|
+
@to_body = @to ? @to.body : @agent.body
|
|
141
|
+
@to_tools = @to ? Array(@to.tools) : Array(@agent.tools)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def load_agent
|
|
147
|
+
if params[:id].to_s =~ /\A\d+\z/
|
|
148
|
+
@agent = Agent.find(params[:id])
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Non-numeric :id — prefer the AR record if a DB row matches by name or slug.
|
|
153
|
+
ar = Agent.where('LOWER(name) = ?', params[:id].to_s.downcase).first
|
|
154
|
+
ar ||= Agent.all.find { |a| slugify(a.name) == params[:id] }
|
|
155
|
+
if ar
|
|
156
|
+
@agent = ar
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
all = AgentLoader.new.load_all_agents
|
|
161
|
+
@agent = all.find { |a| slugify(a['name']) == params[:id] || a['name'] == params[:id] }
|
|
162
|
+
raise ActiveRecord::RecordNotFound, "Agent not found: #{params[:id]}" unless @agent
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def agent_params
|
|
166
|
+
{
|
|
167
|
+
name: params.require(:agent)[:name],
|
|
168
|
+
description: params[:agent][:description],
|
|
169
|
+
body: params[:agent][:body],
|
|
170
|
+
max_rounds: params[:agent][:max_rounds].presence&.to_i,
|
|
171
|
+
model: params[:agent][:model].presence,
|
|
172
|
+
tools: split_lines(params[:agent][:tools])
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def edited_by_param
|
|
177
|
+
params[:edited_by].presence || 'web'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def split_lines(str)
|
|
181
|
+
str.to_s.split(/[\r\n,]+/).map(&:strip).reject(&:empty?)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def slugify(name)
|
|
185
|
+
name.to_s.downcase.strip
|
|
186
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
187
|
+
.gsub(/[\s]+/, '-')
|
|
188
|
+
.gsub(/-+/, '-')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def read_only_message
|
|
192
|
+
if @agent.is_a?(Hash) && @agent['source'] == :builtin
|
|
193
|
+
'This is a built-in agent shipped with the gem. To customize it, create a same-named DB agent override.'
|
|
194
|
+
else
|
|
195
|
+
'This agent lives on disk under .rails_console_ai/agents/. Edit the file directly to change it.'
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module RailsConsoleAi
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
|
+
# Pin to the engine's layout. Without this, Rails layout auto-resolution can
|
|
4
|
+
# fall back to the host app's `layouts/application` (which usually references
|
|
5
|
+
# host-only helpers like `logout_url`).
|
|
6
|
+
layout 'rails_console_ai/application'
|
|
7
|
+
|
|
3
8
|
protect_from_forgery with: :exception
|
|
4
9
|
|
|
5
10
|
before_action :rails_console_ai_authenticate!
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
require 'rails_console_ai/tools/memory_tools'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class MemoriesController < ApplicationController
|
|
5
|
+
before_action :load_memory, only: [:show, :edit, :update, :destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@memories = Tools::MemoryTools.new.load_all_memories
|
|
9
|
+
@q = params[:q].to_s.strip
|
|
10
|
+
unless @q.empty?
|
|
11
|
+
needle = @q.downcase
|
|
12
|
+
@memories = @memories.select { |m|
|
|
13
|
+
[m['name'], m['description'], Array(m['tags']).join(' ')].compact.join(' ').downcase.include?(needle)
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@sort = params[:sort].to_s
|
|
18
|
+
if @sort == 'used'
|
|
19
|
+
@memories = @memories.sort_by { |m| [-(m['use_count'].to_i), m['name'].to_s.downcase] }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
@versions = @memory.versions if @memory.is_a?(RailsConsoleAi::Memory)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def new
|
|
28
|
+
@memory = Memory.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# POST /memories/import — parse a pasted .md blob and re-render `new` with fields prefilled.
|
|
32
|
+
def import
|
|
33
|
+
content = params[:content].to_s
|
|
34
|
+
if content.strip.empty?
|
|
35
|
+
redirect_to new_memory_path, alert: 'Nothing to parse — paste the .md content into the box first.'
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
parsed = Tools::MemoryTools.parse(content)
|
|
40
|
+
if parsed.nil? || parsed['name'].to_s.strip.empty?
|
|
41
|
+
redirect_to new_memory_path,
|
|
42
|
+
alert: 'Could not parse. Expected YAML frontmatter (between `---` lines) with at least a `name` field, followed by the memory body.'
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@memory = Memory.new(
|
|
47
|
+
name: parsed['name'],
|
|
48
|
+
description: parsed['description']
|
|
49
|
+
)
|
|
50
|
+
@memory.tags = Array(parsed['tags'])
|
|
51
|
+
|
|
52
|
+
flash.now[:notice] = "Parsed \"#{parsed['name']}\" from pasted content. Review the fields below and click Create memory to save to the DB."
|
|
53
|
+
render :new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def create
|
|
57
|
+
@memory = Memory.new
|
|
58
|
+
attrs = memory_params
|
|
59
|
+
begin
|
|
60
|
+
@memory.update_with_version!(
|
|
61
|
+
attrs,
|
|
62
|
+
edited_by: edited_by_param,
|
|
63
|
+
change_note: params[:change_note].presence
|
|
64
|
+
)
|
|
65
|
+
redirect_to memory_path(@memory), notice: 'Memory created.'
|
|
66
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
67
|
+
flash.now[:alert] = e.message
|
|
68
|
+
render :new
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def edit
|
|
73
|
+
redirect_to memories_path, alert: file_memory_message and return unless @memory.is_a?(RailsConsoleAi::Memory)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def update
|
|
77
|
+
redirect_to memories_path, alert: file_memory_message and return unless @memory.is_a?(RailsConsoleAi::Memory)
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
@memory.update_with_version!(
|
|
81
|
+
memory_params,
|
|
82
|
+
edited_by: edited_by_param,
|
|
83
|
+
change_note: params[:change_note].presence
|
|
84
|
+
)
|
|
85
|
+
redirect_to memory_path(@memory), notice: 'Memory updated.'
|
|
86
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
87
|
+
flash.now[:alert] = e.message
|
|
88
|
+
render :edit
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def destroy
|
|
93
|
+
if @memory.is_a?(RailsConsoleAi::Memory)
|
|
94
|
+
@memory.destroy
|
|
95
|
+
redirect_to memories_path, notice: 'Memory deleted. Past versions remain in history.'
|
|
96
|
+
else
|
|
97
|
+
redirect_to memories_path, alert: file_memory_message
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def diff
|
|
102
|
+
@memory = Memory.find(params[:memory_id])
|
|
103
|
+
@from = @memory.versions.find(params[:from])
|
|
104
|
+
@to = params[:to].present? ? @memory.versions.find(params[:to]) : nil
|
|
105
|
+
@to_label = @to ? "Version ##{@to.id}" : 'Current'
|
|
106
|
+
@to_description = @to ? @to.description : @memory.description
|
|
107
|
+
@to_tags = @to ? Array(@to.tags) : Array(@memory.tags)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def load_memory
|
|
113
|
+
if params[:id].to_s =~ /\A\d+\z/
|
|
114
|
+
@memory = Memory.find(params[:id])
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Non-numeric :id — prefer the AR record if a DB row matches by name or slug,
|
|
119
|
+
# so write actions (update/destroy) get the AR object, not a read-only Hash.
|
|
120
|
+
ar = Memory.where('LOWER(name) = ?', params[:id].to_s.downcase).first
|
|
121
|
+
ar ||= Memory.all.find { |m| slugify(m.name) == params[:id] }
|
|
122
|
+
if ar
|
|
123
|
+
@memory = ar
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
all = Tools::MemoryTools.new.load_all_memories
|
|
128
|
+
@memory = all.find { |m| slugify(m['name']) == params[:id] || m['name'] == params[:id] }
|
|
129
|
+
raise ActiveRecord::RecordNotFound, "Memory not found: #{params[:id]}" unless @memory
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def memory_params
|
|
133
|
+
{
|
|
134
|
+
name: params.require(:memory)[:name],
|
|
135
|
+
description: params[:memory][:description],
|
|
136
|
+
tags: split_csv(params[:memory][:tags])
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def edited_by_param
|
|
141
|
+
params[:edited_by].presence || 'web'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def split_csv(str)
|
|
145
|
+
str.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def slugify(name)
|
|
149
|
+
name.to_s.downcase.strip
|
|
150
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
151
|
+
.gsub(/[\s]+/, '-')
|
|
152
|
+
.gsub(/-+/, '-')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def file_memory_message
|
|
156
|
+
'This memory lives on disk under .rails_console_ai/memories/. Edit the file directly to change it.'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
class MemoryVersionsController < ApplicationController
|
|
3
|
+
before_action :load_memory
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@versions = @memory.versions
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@version = @memory.versions.find(params[:id])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def restore
|
|
14
|
+
version = @memory.versions.find(params[:id])
|
|
15
|
+
@memory.update_with_version!(
|
|
16
|
+
{
|
|
17
|
+
name: version.name,
|
|
18
|
+
description: version.description,
|
|
19
|
+
tags: Array(version.tags)
|
|
20
|
+
},
|
|
21
|
+
edited_by: params[:edited_by].presence || 'web',
|
|
22
|
+
change_note: "Restored from version ##{version.id}"
|
|
23
|
+
)
|
|
24
|
+
redirect_to memory_path(@memory), notice: "Restored version ##{version.id}."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def load_memory
|
|
30
|
+
@memory = Memory.find(params[:memory_id])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
class SkillVersionsController < ApplicationController
|
|
3
|
+
before_action :load_skill
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@versions = @skill.versions
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@version = @skill.versions.find(params[:id])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def restore
|
|
14
|
+
version = @skill.versions.find(params[:id])
|
|
15
|
+
@skill.update_with_version!(
|
|
16
|
+
{
|
|
17
|
+
name: version.name,
|
|
18
|
+
description: version.description,
|
|
19
|
+
body: version.body,
|
|
20
|
+
tags: Array(version.tags),
|
|
21
|
+
bypass_guards_for_methods: Array(version.bypass_guards_for_methods)
|
|
22
|
+
},
|
|
23
|
+
edited_by: params[:edited_by].presence || 'web',
|
|
24
|
+
change_note: "Restored from version ##{version.id}"
|
|
25
|
+
)
|
|
26
|
+
redirect_to skill_path(@skill), notice: "Restored version ##{version.id}."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def load_skill
|
|
32
|
+
@skill = Skill.find(params[:skill_id])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|