rails_console_ai 0.29.0 → 0.31.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 +46 -0
  3. data/README.md +65 -0
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +29 -0
  5. data/app/controllers/rails_console_ai/agents_controller.rb +176 -0
  6. data/app/controllers/rails_console_ai/application_controller.rb +5 -0
  7. data/app/controllers/rails_console_ai/memories_controller.rb +136 -0
  8. data/app/controllers/rails_console_ai/memory_versions_controller.rb +29 -0
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +29 -0
  10. data/app/controllers/rails_console_ai/skills_controller.rb +171 -0
  11. data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
  12. data/app/models/rails_console_ai/agent.rb +143 -0
  13. data/app/models/rails_console_ai/agent_version.rb +34 -0
  14. data/app/models/rails_console_ai/memory.rb +103 -0
  15. data/app/models/rails_console_ai/memory_version.rb +31 -0
  16. data/app/models/rails_console_ai/session.rb +1 -1
  17. data/app/models/rails_console_ai/skill.rb +148 -0
  18. data/app/models/rails_console_ai/skill_version.rb +33 -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 +40 -0
  23. data/app/views/rails_console_ai/agents/diff.html.erb +15 -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 +8 -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 +29 -0
  29. data/app/views/rails_console_ai/memories/diff.html.erb +15 -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 +8 -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 +43 -0
  39. data/app/views/rails_console_ai/skills/diff.html.erb +15 -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 +8 -0
  43. data/app/views/rails_console_ai/skills/show.html.erb +94 -0
  44. data/config/routes.rb +39 -0
  45. data/lib/rails_console_ai/agent_loader.rb +139 -43
  46. data/lib/rails_console_ai/agent_runner.rb +209 -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 +10 -0
  50. data/lib/rails_console_ai/skill_loader.rb +130 -29
  51. data/lib/rails_console_ai/storage/database_storage.rb +195 -0
  52. data/lib/rails_console_ai/tools/memory_tools.rb +110 -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 +240 -0
  56. data/lib/tasks/rails_console_ai.rake +7 -0
  57. metadata +55 -1
@@ -0,0 +1,148 @@
1
+ require 'rails_console_ai/skill_loader'
2
+
3
+ module RailsConsoleAi
4
+ class Skill < ActiveRecord::Base
5
+ self.table_name = 'rails_console_ai_skills'
6
+
7
+ STATUS_PROPOSED = 'proposed'.freeze
8
+ STATUS_APPROVED = 'approved'.freeze
9
+ STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
10
+
11
+ has_many :versions,
12
+ -> { order(created_at: :desc) },
13
+ class_name: 'RailsConsoleAi::SkillVersion',
14
+ foreign_key: :skill_id,
15
+ dependent: :nullify
16
+
17
+ validates :content, presence: true
18
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validate :content_parses
21
+
22
+ before_validation :sync_name_from_content
23
+
24
+ scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
25
+ scope :approved, -> { where(status: STATUS_APPROVED) }
26
+ scope :proposed, -> { where(status: STATUS_PROPOSED) }
27
+
28
+ def self.connection
29
+ klass = RailsConsoleAi.configuration.connection_class
30
+ if klass
31
+ klass = Object.const_get(klass) if klass.is_a?(String)
32
+ klass.connection
33
+ else
34
+ super
35
+ end
36
+ end
37
+
38
+ # Parsed view of the raw markdown content. Memoized per-instance and cleared
39
+ # on assignment. Returns {} for invalid content so callers don't need to nil-guard.
40
+ def parsed
41
+ @parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
42
+ end
43
+
44
+ def content=(value)
45
+ @parsed = nil
46
+ super
47
+ end
48
+
49
+ def description; parsed['description']; end
50
+ def body; parsed['body']; end
51
+ def tags; Array(parsed['tags']); end
52
+ def bypass_guards_for_methods; Array(parsed['bypass_guards_for_methods']); end
53
+
54
+ def proposed?; status.to_s == STATUS_PROPOSED; end
55
+ def approved?; status.to_s == STATUS_APPROVED; end
56
+
57
+ # Atomically bump use_count + last_used_at without firing callbacks /
58
+ # validations / updated_at. Safe to call from concurrent AI tool calls.
59
+ def self.record_use!(id)
60
+ where(id: id).update_all([
61
+ 'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
62
+ Time.now.utc
63
+ ])
64
+ true
65
+ rescue ::ActiveRecord::ActiveRecordError => e
66
+ RailsConsoleAi.logger.warn("RailsConsoleAi::Skill.record_use!(#{id.inspect}) failed: #{e.message}")
67
+ false
68
+ end
69
+
70
+ def to_hash
71
+ {
72
+ 'id' => id,
73
+ 'name' => name,
74
+ 'description' => description,
75
+ 'body' => body,
76
+ 'tags' => tags,
77
+ 'bypass_guards_for_methods' => bypass_guards_for_methods,
78
+ 'content' => content,
79
+ 'status' => status,
80
+ 'approved_by' => approved_by,
81
+ 'approved_at' => approved_at,
82
+ 'use_count' => use_count,
83
+ 'last_used_at' => last_used_at,
84
+ 'source' => :db,
85
+ 'updated_at' => updated_at
86
+ }
87
+ end
88
+
89
+ # Assigns attrs, saves, and records one SkillVersion snapshot.
90
+ # Any change to `content` reverts approval back to "proposed" unless
91
+ # `preserve_approval: true` is passed (approve! does this).
92
+ def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
93
+ transaction do
94
+ assign_attributes(attrs)
95
+
96
+ if !preserve_approval && approved? && changes.key?('content')
97
+ self.status = STATUS_PROPOSED
98
+ self.approved_by = nil
99
+ self.approved_at = nil
100
+ end
101
+
102
+ save!
103
+ RailsConsoleAi::SkillVersion.create!(
104
+ skill_id: id,
105
+ name: name,
106
+ content: content,
107
+ status: status,
108
+ edited_by: edited_by,
109
+ change_note: change_note
110
+ )
111
+ end
112
+ self
113
+ end
114
+
115
+ def approve!(approved_by:)
116
+ raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
117
+
118
+ update_with_version!(
119
+ {
120
+ status: STATUS_APPROVED,
121
+ approved_by: approved_by,
122
+ approved_at: Time.now.utc
123
+ },
124
+ edited_by: approved_by,
125
+ change_note: "Approved by #{approved_by}",
126
+ preserve_approval: true
127
+ )
128
+ end
129
+
130
+ private
131
+
132
+ def sync_name_from_content
133
+ return if content.to_s.strip.empty?
134
+ parsed_name = parsed['name'].to_s.strip
135
+ self.name = parsed_name unless parsed_name.empty?
136
+ end
137
+
138
+ def content_parses
139
+ return if content.to_s.strip.empty? # presence validator handles blank
140
+ hash = RailsConsoleAi::SkillLoader.parse(content.to_s)
141
+ if hash.nil?
142
+ errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
143
+ elsif hash['name'].to_s.strip.empty?
144
+ errors.add(:content, "frontmatter is missing a `name:` field")
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,33 @@
1
+ require 'rails_console_ai/skill_loader'
2
+
3
+ module RailsConsoleAi
4
+ class SkillVersion < ActiveRecord::Base
5
+ self.table_name = 'rails_console_ai_skill_versions'
6
+
7
+ belongs_to :skill,
8
+ class_name: 'RailsConsoleAi::Skill',
9
+ foreign_key: :skill_id,
10
+ optional: true
11
+
12
+ scope :recent, -> { order(created_at: :desc) }
13
+
14
+ def self.connection
15
+ klass = RailsConsoleAi.configuration.connection_class
16
+ if klass
17
+ klass = Object.const_get(klass) if klass.is_a?(String)
18
+ klass.connection
19
+ else
20
+ super
21
+ end
22
+ end
23
+
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
26
+ end
27
+
28
+ def description; parsed['description']; end
29
+ def body; parsed['body']; end
30
+ def tags; Array(parsed['tags']); end
31
+ def bypass_guards_for_methods; Array(parsed['bypass_guards_for_methods']); end
32
+ end
33
+ end
@@ -70,14 +70,91 @@
70
70
  .pagination a:hover { background: #f8f9fa; text-decoration: none; }
71
71
  .pagination .disabled { padding: 6px 14px; font-size: 13px; color: #ccc; }
72
72
  .pagination .page-info { font-size: 13px; color: #888; }
73
+ .nav-links a { margin-left: 16px; }
74
+ .btn {
75
+ display: inline-block; padding: 7px 14px; border-radius: 4px;
76
+ background: #4a6fa5; color: #fff !important; border: 0;
77
+ font-size: 13px; cursor: pointer; text-decoration: none;
78
+ }
79
+ .btn:hover { background: #3a5a85; text-decoration: none; }
80
+ .btn-secondary { background: #6c757d; }
81
+ .btn-secondary:hover { background: #5a6268; }
82
+ .btn-danger { background: #dc3545; }
83
+ .btn-danger:hover { background: #c82333; }
84
+ .btn-bar { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
85
+ .source-db { background: #cce5ff; color: #004085; }
86
+ .source-file { background: #e2e3e5; color: #383d41; }
87
+ .source-builtin { background: #e0d4ff; color: #4a2d8c; }
88
+ .status-approved { background: #d4edda; color: #155724; }
89
+ .status-proposed { background: #fff3cd; color: #856404; }
90
+ .form-row { margin-bottom: 14px; }
91
+ .form-row label {
92
+ display: block; font-size: 12px; font-weight: 600;
93
+ color: #555; text-transform: uppercase; margin-bottom: 4px;
94
+ }
95
+ .form-row input[type="text"], .form-row textarea {
96
+ width: 100%; padding: 8px 10px; font-size: 14px;
97
+ border: 1px solid #ccc; border-radius: 4px;
98
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
99
+ }
100
+ .form-row textarea {
101
+ font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
102
+ font-size: 13px;
103
+ }
104
+ .form-row .hint { font-size: 12px; color: #888; margin-top: 4px; }
105
+ .form-card {
106
+ background: #fff; border-radius: 8px; padding: 24px;
107
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
108
+ }
109
+ .flash { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; font-size: 14px; }
110
+ .flash-notice { background: #d4edda; color: #155724; }
111
+ .flash-alert { background: #f8d7da; color: #721c24; }
112
+ .markdown-body {
113
+ background: #fff; border-radius: 8px; padding: 20px;
114
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px;
115
+ white-space: pre-wrap; font-family: "SF Mono", monospace; font-size: 13px;
116
+ }
117
+ .versions-list { background: #fff; border-radius: 8px; padding: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
118
+ .versions-list .ver-row {
119
+ display: flex; align-items: center; justify-content: space-between;
120
+ padding: 10px 16px; border-bottom: 1px solid #f0f0f0; font-size: 13px;
121
+ }
122
+ .versions-list .ver-row:last-child { border-bottom: 0; }
123
+ .versions-list .ver-meta { color: #888; font-size: 12px; }
124
+ .diff-table {
125
+ width: 100%; border-collapse: collapse; background: #fff;
126
+ border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
127
+ }
128
+ .diff-table th { background: #f8f9fa; padding: 8px 12px; text-align: left; font-size: 12px; }
129
+ .diff-table td {
130
+ vertical-align: top; padding: 0; width: 50%; border-bottom: 1px solid #f0f0f0;
131
+ }
132
+ .diff-table pre {
133
+ margin: 0; padding: 4px 12px; font-family: "SF Mono", monospace;
134
+ font-size: 12px; white-space: pre-wrap; word-break: break-word;
135
+ }
136
+ .diff-add { background: #d4edda; }
137
+ .diff-del { background: #f8d7da; }
138
+ .tag {
139
+ display: inline-block; padding: 2px 8px; margin-right: 4px;
140
+ background: #e9ecef; color: #495057; border-radius: 10px; font-size: 11px;
141
+ }
142
+ .inline-form { display: inline; }
73
143
  </style>
74
144
  </head>
75
145
  <body>
76
146
  <div class="header">
77
147
  <h1>RailsConsoleAi Admin</h1>
78
- <a href="<%= rails_console_ai.root_path %>">Sessions</a>
148
+ <div class="nav-links">
149
+ <a href="<%= rails_console_ai.root_path %>">Sessions</a>
150
+ <a href="<%= rails_console_ai.skills_path %>">Skills</a>
151
+ <a href="<%= rails_console_ai.memories_path %>">Memories</a>
152
+ <a href="<%= rails_console_ai.agents_path %>">Agents</a>
153
+ </div>
79
154
  </div>
80
155
  <div class="container">
156
+ <% if flash[:notice] %><div class="flash flash-notice"><%= flash[:notice] %></div><% end %>
157
+ <% if flash[:alert] %><div class="flash flash-alert"><%= flash[:alert] %></div><% end %>
81
158
  <%= yield %>
82
159
  </div>
83
160
  </body>
@@ -0,0 +1,28 @@
1
+ <%= link_to '← Back to agent', agent_path(@agent), class: 'back-link' %>
2
+
3
+ <h2 style="margin-bottom:16px;">Versions — <%= @agent.name %></h2>
4
+
5
+ <% if @versions.empty? %>
6
+ <div class="meta-card"><p class="text-muted">No versions recorded yet.</p></div>
7
+ <% else %>
8
+ <div class="versions-list">
9
+ <% @versions.each do |v| %>
10
+ <div class="ver-row">
11
+ <div>
12
+ <strong>v<%= v.id %></strong>
13
+ <span class="ver-meta">— <%= v.created_at.strftime('%Y-%m-%d %H:%M:%S') %> by <%= v.edited_by || 'unknown' %></span>
14
+ <% if v.status == 'approved' %><span class="badge status-approved">APPROVED</span>
15
+ <% elsif v.status == 'proposed' %><span class="badge status-proposed">PROPOSED</span><% end %>
16
+ <% if v.change_note.present? %><div class="ver-meta"><%= v.change_note %></div><% end %>
17
+ </div>
18
+ <div>
19
+ <%= link_to 'View', agent_version_path(@agent, v) %>
20
+ ·
21
+ <%= link_to 'Diff vs current', diff_agents_path(agent_id: @agent.id, from: v.id) %>
22
+ ·
23
+ <%= button_to 'Restore', restore_agent_version_path(@agent, v), method: :post, class: 'btn btn-secondary', form: { style: 'display:inline' }, data: { confirm: "Restore version ##{v.id}? Restoring reverts the agent to PROPOSED and a new version row will be created." } %>
24
+ </div>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <%= link_to '← All versions', agent_versions_path(@agent), class: 'back-link' %>
2
+
3
+ <div class="meta-card">
4
+ <h2 style="margin-bottom:8px;"><%= @version.name %> <span class="badge source-db">v<%= @version.id %></span></h2>
5
+ <p class="text-muted"><%= @version.description %></p>
6
+ <div class="meta-grid" style="margin-top:16px;">
7
+ <div class="meta-item"><label>Edited by</label><span><%= @version.edited_by || '—' %></span></div>
8
+ <div class="meta-item"><label>At</label><span><%= @version.created_at.strftime('%Y-%m-%d %H:%M:%S') %></span></div>
9
+ <div class="meta-item"><label>Status</label><span><%= @version.status || '—' %></span></div>
10
+ <div class="meta-item"><label>Model</label><span><%= @version.model || '(default)' %></span></div>
11
+ <div class="meta-item"><label>Max rounds</label><span><%= @version.max_rounds || '(default)' %></span></div>
12
+ <div class="meta-item"><label>Tools</label><span><%= Array(@version.tools).join(', ').presence || '(default set)' %></span></div>
13
+ </div>
14
+ <% if @version.change_note.present? %>
15
+ <p style="margin-top:12px;"><strong>Change note:</strong> <%= @version.change_note %></p>
16
+ <% end %>
17
+ </div>
18
+
19
+ <div class="btn-bar">
20
+ <%= button_to "Restore this version", restore_agent_version_path(@agent, @version), method: :post, class: 'btn btn-danger', data: { confirm: "Restore version ##{@version.id}? Current agent will be overwritten and status reverted to PROPOSED." } %>
21
+ <%= link_to 'Diff vs current', diff_agents_path(agent_id: @agent.id, from: @version.id), class: 'btn btn-secondary' %>
22
+ </div>
23
+
24
+ <h3 style="margin-top:24px; margin-bottom:8px;">Body / instructions</h3>
25
+ <div class="markdown-body"><%= @version.body %></div>
@@ -0,0 +1,40 @@
1
+ <% if @agent.persisted? && @agent.respond_to?(:approved?) && @agent.approved? %>
2
+ <div class="flash flash-alert" style="margin-bottom:16px;">
3
+ Heads up: this agent is currently <strong>approved</strong>. Editing it will revert its status to <strong>proposed</strong>.
4
+ </div>
5
+ <% elsif !@agent.persisted? %>
6
+ <div class="flash flash-notice" style="margin-bottom:16px;">
7
+ New DB agents start as <strong>proposed</strong>. After saving, an approver (likely you) needs to click the Approve button before delegate_task can invoke it.
8
+ </div>
9
+ <% end %>
10
+
11
+ <%= form_with model: @agent, url: form_url, method: form_method, local: true do |f| %>
12
+ <div class="form-card">
13
+ <div class="form-row">
14
+ <label>Markdown (frontmatter + body)</label>
15
+ <%= f.text_area :content, value: @agent.content, rows: 28, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
16
+ <div class="hint">
17
+ YAML frontmatter between <code>---</code> lines, then the agent persona / instructions. Required:
18
+ <code>name</code>, <code>description</code>. Optional: <code>max_rounds</code>, <code>model</code>,
19
+ <code>tools</code> (whitelist; empty/omitted = default set).
20
+ </div>
21
+ </div>
22
+
23
+ <hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
24
+
25
+ <div class="form-row">
26
+ <label>Edited by</label>
27
+ <input type="text" name="edited_by" value="<%= params[:edited_by] %>" placeholder="Your name or handle">
28
+ </div>
29
+
30
+ <div class="form-row">
31
+ <label>Change note (optional)</label>
32
+ <input type="text" name="change_note" value="" placeholder="What did you change and why?">
33
+ </div>
34
+ </div>
35
+
36
+ <div class="btn-bar">
37
+ <%= f.submit submit_label, class: 'btn' %>
38
+ <%= link_to 'Cancel', cancel_path, class: 'btn btn-secondary' %>
39
+ </div>
40
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%= link_to '← Back to agent', agent_path(@agent), class: 'back-link' %>
2
+
3
+ <h2 style="margin-bottom:8px;">Diff — <%= @agent.name %></h2>
4
+ <p class="text-muted" style="margin-bottom:16px;">
5
+ Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
+ </p>
7
+
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
9
+
10
+ <div class="btn-bar" style="margin-top:24px;">
11
+ <% if @to.nil? %>
12
+ <%= button_to "Restore v##{@from.id}", restore_agent_version_path(@agent, @from), method: :post, class: 'btn btn-danger', data: { confirm: 'Restoring will overwrite the current agent (a new version row is created) and revert status to PROPOSED.' } %>
13
+ <% end %>
14
+ <%= link_to 'Back', agent_path(@agent), class: 'btn btn-secondary' %>
15
+ </div>
@@ -0,0 +1,7 @@
1
+ <%= link_to '← Back to agent', agent_path(@agent), class: 'back-link' %>
2
+ <h2 style="margin-bottom:16px;">Edit agent</h2>
3
+ <%= render 'form',
4
+ form_url: agent_path(@agent),
5
+ form_method: :patch,
6
+ submit_label: 'Save changes',
7
+ cancel_path: agent_path(@agent) %>
@@ -0,0 +1,80 @@
1
+ <div class="btn-bar">
2
+ <%= link_to 'New agent', new_agent_path, class: 'btn' %>
3
+ <% if @sort == 'used' %>
4
+ <%= link_to 'Sort: alphabetical', agents_path(q: @q), class: 'btn btn-secondary' %>
5
+ <% else %>
6
+ <%= link_to 'Sort: most used', agents_path(q: @q, sort: 'used'), class: 'btn btn-secondary' %>
7
+ <% end %>
8
+ <form method="get" action="<%= agents_path %>" style="margin-left:auto;">
9
+ <input type="text" name="q" value="<%= @q %>" placeholder="Search agents…" style="padding:6px 10px; border:1px solid #ccc; border-radius:4px;">
10
+ <% if @sort.present? %><input type="hidden" name="sort" value="<%= @sort %>"><% end %>
11
+ </form>
12
+ </div>
13
+
14
+ <% proposed_count = @agents.count { |a| a['source'] == :db && a['status'] != 'approved' } %>
15
+ <% if proposed_count > 0 %>
16
+ <div class="flash flash-alert" style="margin-bottom:16px;">
17
+ <strong><%= proposed_count %></strong> agent<%= proposed_count == 1 ? '' : 's' %> awaiting approval. The AI cannot invoke them via delegate_task until a human approves.
18
+ </div>
19
+ <% end %>
20
+
21
+ <% if @agents.empty? %>
22
+ <div class="meta-card">
23
+ <p class="text-muted">No agents found. <%= link_to 'Create one', new_agent_path %> or drop a Markdown file in <code>.rails_console_ai/agents/</code>.</p>
24
+ </div>
25
+ <% else %>
26
+ <table>
27
+ <thead>
28
+ <tr>
29
+ <th>Name</th>
30
+ <th>Description</th>
31
+ <th>Model</th>
32
+ <th>Source / Status</th>
33
+ <th title="Number of times delegate_task invoked this agent (DB only)">Uses</th>
34
+ <th>Last used</th>
35
+ <th></th>
36
+ </tr>
37
+ </thead>
38
+ <tbody>
39
+ <% @agents.each do |a| %>
40
+ <% link_id = a['id'] || a['name'] %>
41
+ <tr>
42
+ <td><strong><%= link_to a['name'], agent_path(link_id) %></strong></td>
43
+ <td class="query-cell"><%= a['description'] %></td>
44
+ <td class="mono"><%= a['model'] || '—' %></td>
45
+ <td>
46
+ <% case a['source'] %>
47
+ <% when :db %>
48
+ <span class="badge source-db">DB</span>
49
+ <% if a['status'] == 'approved' %>
50
+ <span class="badge status-approved">APPROVED</span>
51
+ <% else %>
52
+ <span class="badge status-proposed">PROPOSED</span>
53
+ <% end %>
54
+ <% when :file %>
55
+ <span class="badge source-file">FILE</span>
56
+ <% when :builtin %>
57
+ <span class="badge source-builtin">BUILTIN</span>
58
+ <% end %>
59
+ </td>
60
+ <td>
61
+ <% if a['source'] == :db %>
62
+ <%= a['use_count'] || 0 %>
63
+ <% else %>
64
+ <span class="text-muted">—</span>
65
+ <% end %>
66
+ </td>
67
+ <td class="text-muted" style="font-size:12px;">
68
+ <%= a['last_used_at']&.strftime('%Y-%m-%d %H:%M') || '—' %>
69
+ </td>
70
+ <td>
71
+ <%= link_to 'View', agent_path(link_id) %>
72
+ <% if a['source'] == :db %>
73
+ · <%= link_to 'Edit', edit_agent_path(a['id']) %>
74
+ <% end %>
75
+ </td>
76
+ </tr>
77
+ <% end %>
78
+ </tbody>
79
+ </table>
80
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <%= link_to '← All agents', agents_path, class: 'back-link' %>
2
+ <h2 style="margin-bottom:16px;">New agent</h2>
3
+
4
+ <%= render 'form',
5
+ form_url: agents_path,
6
+ form_method: :post,
7
+ submit_label: 'Create agent',
8
+ cancel_path: agents_path %>
@@ -0,0 +1,108 @@
1
+ <%
2
+ is_db = @agent.is_a?(RailsConsoleAi::Agent)
3
+ is_builtin = !is_db && @agent['source'] == :builtin
4
+ is_file = !is_db && @agent['source'] == :file
5
+
6
+ name = is_db ? @agent.name : @agent['name']
7
+ description = is_db ? @agent.description : @agent['description']
8
+ body = is_db ? @agent.body : @agent['body']
9
+ max_rounds = is_db ? @agent.max_rounds : @agent['max_rounds']
10
+ model = is_db ? @agent.model : @agent['model']
11
+ tools = is_db ? Array(@agent.tools) : Array(@agent['tools'])
12
+ file_key = is_db ? nil : @agent['file_key']
13
+ %>
14
+
15
+ <%= link_to '← All agents', agents_path, class: 'back-link' %>
16
+
17
+ <% if is_db && !@agent.approved? %>
18
+ <div class="flash flash-alert" style="margin-bottom:16px;">
19
+ <strong>Awaiting approval.</strong> The AI cannot invoke this agent via delegate_task until a human approves it.
20
+ Review the body and tools list below, then click <strong>Approve</strong>.
21
+ </div>
22
+ <% end %>
23
+
24
+ <% if is_builtin %>
25
+ <div class="flash flash-notice" style="margin-bottom:16px;">
26
+ This is a <strong>built-in agent</strong> shipped with the gem. It's read-only here.
27
+ To customize it, <%= link_to 'create a same-named DB override', new_agent_path(from_builtin: name) %> — your DB agent will shadow this one.
28
+ </div>
29
+ <% end %>
30
+
31
+ <div class="meta-card">
32
+ <h2 style="margin-bottom:8px;">
33
+ <%= name %>
34
+ <% if is_db %>
35
+ <span class="badge source-db">DB</span>
36
+ <% if @agent.approved? %>
37
+ <span class="badge status-approved">APPROVED</span>
38
+ <% else %>
39
+ <span class="badge status-proposed">PROPOSED</span>
40
+ <% end %>
41
+ <% elsif is_builtin %>
42
+ <span class="badge source-builtin">BUILTIN</span>
43
+ <% else %>
44
+ <span class="badge source-file">FILE</span>
45
+ <% end %>
46
+ </h2>
47
+ <p class="text-muted"><%= description %></p>
48
+ <div class="meta-grid" style="margin-top:16px;">
49
+ <div class="meta-item"><label>Model</label><span><%= model || '(default)' %></span></div>
50
+ <div class="meta-item"><label>Max rounds</label><span><%= max_rounds || '(default)' %></span></div>
51
+ <div class="meta-item"><label>Tool whitelist</label><span><%= tools.empty? ? '(default set)' : tools.join(', ') %></span></div>
52
+ <% if is_db %>
53
+ <div class="meta-item"><label>Updated</label><span><%= @agent.updated_at.strftime('%Y-%m-%d %H:%M') %></span></div>
54
+ <div class="meta-item"><label>Versions</label><span><%= @agent.versions.count %></span></div>
55
+ <div class="meta-item"><label>Times invoked</label><span><%= @agent.use_count %></span></div>
56
+ <div class="meta-item"><label>Last invoked</label><span><%= @agent.last_used_at&.strftime('%Y-%m-%d %H:%M') || 'never' %></span></div>
57
+ <% if @agent.approved? %>
58
+ <div class="meta-item"><label>Approved by</label><span><%= @agent.approved_by || '—' %></span></div>
59
+ <div class="meta-item"><label>Approved at</label><span><%= @agent.approved_at&.strftime('%Y-%m-%d %H:%M') || '—' %></span></div>
60
+ <% end %>
61
+ <% else %>
62
+ <div class="meta-item"><label>Path</label><span class="mono"><%= file_key %></span></div>
63
+ <% end %>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="btn-bar">
68
+ <% if is_db %>
69
+ <% unless @agent.approved? %>
70
+ <%= form_with url: approve_agent_path(@agent), method: :post, local: true, style: 'display:inline' do %>
71
+ <input type="text" name="approved_by" placeholder="Your name (required)" style="padding:7px 10px; border:1px solid #ccc; border-radius:4px; margin-right:6px;">
72
+ <button type="submit" class="btn btn-danger" data-confirm="Approve this agent? The AI will then be able to invoke it via delegate_task with the configured tool whitelist.">Approve</button>
73
+ <% end %>
74
+ <% end %>
75
+ <%= link_to 'Edit', edit_agent_path(@agent), class: 'btn' %>
76
+ <%= link_to 'Versions', agent_versions_path(@agent), class: 'btn btn-secondary' %>
77
+ <%= button_to 'Delete', agent_path(@agent), method: :delete, class: 'btn btn-danger', form: { style: 'display:inline' }, data: { confirm: 'Delete this agent? Past versions remain in history.' } %>
78
+ <% elsif is_builtin %>
79
+ <%= link_to 'Create DB override', new_agent_path(from_builtin: name), class: 'btn' %>
80
+ <% end %>
81
+ </div>
82
+
83
+ <h3 style="margin-bottom:8px;">Body / instructions</h3>
84
+ <div class="markdown-body"><%= body %></div>
85
+
86
+ <% if is_db && @versions && @versions.any? %>
87
+ <h3 style="margin:24px 0 8px;">Recent versions</h3>
88
+ <div class="versions-list">
89
+ <% @versions.first(5).each do |v| %>
90
+ <div class="ver-row">
91
+ <div>
92
+ <strong>v<%= v.id %></strong>
93
+ <span class="ver-meta">— <%= v.created_at.strftime('%Y-%m-%d %H:%M') %> by <%= v.edited_by || 'unknown' %></span>
94
+ <% if v.status == 'approved' %><span class="badge status-approved">APPROVED</span><% end %>
95
+ <% if v.change_note.present? %><div class="ver-meta"><%= v.change_note %></div><% end %>
96
+ </div>
97
+ <div>
98
+ <%= link_to 'View', agent_version_path(@agent, v) %>
99
+ ·
100
+ <%= link_to 'Diff vs current', diff_agents_path(agent_id: @agent.id, from: v.id) %>
101
+ </div>
102
+ </div>
103
+ <% end %>
104
+ </div>
105
+ <% if @versions.size > 5 %>
106
+ <p style="margin-top:8px;"><%= link_to "See all #{@versions.size} versions →", agent_versions_path(@agent) %></p>
107
+ <% end %>
108
+ <% end %>
@@ -0,0 +1,29 @@
1
+ <%= form_with model: @memory, url: form_url, method: form_method, local: true do |f| %>
2
+ <div class="form-card">
3
+ <div class="form-row">
4
+ <label>Markdown (frontmatter + body)</label>
5
+ <%= f.text_area :content, value: @memory.content, rows: 22, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
6
+ <div class="hint">
7
+ YAML frontmatter between <code>---</code> lines (required: <code>name</code>; optional: <code>tags</code>),
8
+ then the memory body — the fact or pattern you're persisting.
9
+ </div>
10
+ </div>
11
+
12
+ <hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
13
+
14
+ <div class="form-row">
15
+ <label>Edited by</label>
16
+ <input type="text" name="edited_by" value="<%= params[:edited_by] %>" placeholder="Your name or handle">
17
+ </div>
18
+
19
+ <div class="form-row">
20
+ <label>Change note (optional)</label>
21
+ <input type="text" name="change_note" value="" placeholder="What did you change and why?">
22
+ </div>
23
+ </div>
24
+
25
+ <div class="btn-bar">
26
+ <%= f.submit submit_label, class: 'btn' %>
27
+ <%= link_to 'Cancel', cancel_path, class: 'btn btn-secondary' %>
28
+ </div>
29
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%= link_to '← Back to memory', memory_path(@memory), class: 'back-link' %>
2
+
3
+ <h2 style="margin-bottom:8px;">Diff — <%= @memory.name %></h2>
4
+ <p class="text-muted" style="margin-bottom:16px;">
5
+ Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
+ </p>
7
+
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
9
+
10
+ <div class="btn-bar" style="margin-top:24px;">
11
+ <% if @to.nil? %>
12
+ <%= button_to "Restore v##{@from.id}", restore_memory_version_path(@memory, @from), method: :post, class: 'btn btn-danger', data: { confirm: 'Restoring will overwrite the current memory (a new version row is created).' } %>
13
+ <% end %>
14
+ <%= link_to 'Back', memory_path(@memory), class: 'btn btn-secondary' %>
15
+ </div>
@@ -0,0 +1,7 @@
1
+ <%= link_to '← Back to memory', memory_path(@memory), class: 'back-link' %>
2
+ <h2 style="margin-bottom:16px;">Edit memory</h2>
3
+ <%= render 'form',
4
+ form_url: memory_path(@memory),
5
+ form_method: :patch,
6
+ submit_label: 'Save changes',
7
+ cancel_path: memory_path(@memory) %>