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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +48 -0
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
  5. data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
  6. data/app/controllers/rails_console_ai/application_controller.rb +5 -0
  7. data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
  8. data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
  10. data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
  11. data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
  12. data/app/models/rails_console_ai/agent.rb +175 -0
  13. data/app/models/rails_console_ai/agent_version.rb +46 -0
  14. data/app/models/rails_console_ai/memory.rb +98 -0
  15. data/app/models/rails_console_ai/memory_version.rb +46 -0
  16. data/app/models/rails_console_ai/session.rb +1 -1
  17. data/app/models/rails_console_ai/skill.rb +198 -0
  18. data/app/models/rails_console_ai/skill_version.rb +54 -0
  19. data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
  20. data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
  21. data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
  22. data/app/views/rails_console_ai/agents/_form.html.erb +65 -0
  23. data/app/views/rails_console_ai/agents/diff.html.erb +19 -0
  24. data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
  25. data/app/views/rails_console_ai/agents/index.html.erb +80 -0
  26. data/app/views/rails_console_ai/agents/new.html.erb +24 -0
  27. data/app/views/rails_console_ai/agents/show.html.erb +108 -0
  28. data/app/views/rails_console_ai/memories/_form.html.erb +36 -0
  29. data/app/views/rails_console_ai/memories/diff.html.erb +19 -0
  30. data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
  31. data/app/views/rails_console_ai/memories/index.html.erb +67 -0
  32. data/app/views/rails_console_ai/memories/new.html.erb +23 -0
  33. data/app/views/rails_console_ai/memories/show.html.erb +65 -0
  34. data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
  35. data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
  36. data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
  37. data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
  38. data/app/views/rails_console_ai/skills/_form.html.erb +65 -0
  39. data/app/views/rails_console_ai/skills/diff.html.erb +22 -0
  40. data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
  41. data/app/views/rails_console_ai/skills/index.html.erb +79 -0
  42. data/app/views/rails_console_ai/skills/new.html.erb +25 -0
  43. data/app/views/rails_console_ai/skills/show.html.erb +94 -0
  44. data/config/routes.rb +42 -0
  45. data/lib/rails_console_ai/agent_loader.rb +131 -43
  46. data/lib/rails_console_ai/agent_runner.rb +158 -0
  47. data/lib/rails_console_ai/channel/api.rb +139 -0
  48. data/lib/rails_console_ai/conversation_engine.rb +19 -13
  49. data/lib/rails_console_ai/session_logger.rb +6 -0
  50. data/lib/rails_console_ai/skill_loader.rb +119 -27
  51. data/lib/rails_console_ai/storage/database_storage.rb +201 -0
  52. data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
  53. data/lib/rails_console_ai/tools/registry.rb +99 -8
  54. data/lib/rails_console_ai/version.rb +1 -1
  55. data/lib/rails_console_ai.rb +256 -0
  56. data/lib/tasks/rails_console_ai.rake +7 -0
  57. metadata +55 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c7955113bc3e4ce45989fcc0e93034185fbabc0e95acfa01df56ec397900b5
4
- data.tar.gz: 87b0399d973e448404b234820067ed2f20d4148f78613963904c9e288a17d87a
3
+ metadata.gz: 7455c7e1d55abd75c69bfa99621f1f83ec53a5faa2313e3095b3d80b88ec9938
4
+ data.tar.gz: 0a12cdef783d5f101d0c45b7fce9876e54bf31b1874fc4178a5d2103064ecb0a
5
5
  SHA512:
6
- metadata.gz: ad1091c711239e286408f0133378c3597c83075b3f9435a40f43bf2df4fa279cf459cf3af84c58027842d2da4c8bccc5d11bfbec616c6e3ec1212784ad59b764
7
- data.tar.gz: 61ea9cc3afd50b35ec3d0740b723a076175731d1289c4a98ee5727aaec78c993abb0b78637cb6ac7ef4b550b81b473ff287d83c32405c8fbd345dbf05b0b3b72
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