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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +65 -0
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +29 -0
- data/app/controllers/rails_console_ai/agents_controller.rb +176 -0
- data/app/controllers/rails_console_ai/application_controller.rb +5 -0
- data/app/controllers/rails_console_ai/memories_controller.rb +136 -0
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +29 -0
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +29 -0
- data/app/controllers/rails_console_ai/skills_controller.rb +171 -0
- data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
- data/app/models/rails_console_ai/agent.rb +143 -0
- data/app/models/rails_console_ai/agent_version.rb +34 -0
- data/app/models/rails_console_ai/memory.rb +103 -0
- data/app/models/rails_console_ai/memory_version.rb +31 -0
- data/app/models/rails_console_ai/session.rb +1 -1
- data/app/models/rails_console_ai/skill.rb +148 -0
- data/app/models/rails_console_ai/skill_version.rb +33 -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 +40 -0
- data/app/views/rails_console_ai/agents/diff.html.erb +15 -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 +8 -0
- data/app/views/rails_console_ai/agents/show.html.erb +108 -0
- data/app/views/rails_console_ai/memories/_form.html.erb +29 -0
- data/app/views/rails_console_ai/memories/diff.html.erb +15 -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 +8 -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 +43 -0
- data/app/views/rails_console_ai/skills/diff.html.erb +15 -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 +8 -0
- data/app/views/rails_console_ai/skills/show.html.erb +94 -0
- data/config/routes.rb +39 -0
- data/lib/rails_console_ai/agent_loader.rb +139 -43
- data/lib/rails_console_ai/agent_runner.rb +209 -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 +10 -0
- data/lib/rails_console_ai/skill_loader.rb +130 -29
- data/lib/rails_console_ai/storage/database_storage.rb +195 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +110 -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 +240 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +55 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require 'rails_console_ai/skill_loader'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class SkillsController < ApplicationController
|
|
5
|
+
before_action :load_skill, only: [:show, :edit, :update, :destroy, :approve]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@skills = SkillLoader.new.load_all_skills
|
|
9
|
+
@q = params[:q].to_s.strip
|
|
10
|
+
unless @q.empty?
|
|
11
|
+
needle = @q.downcase
|
|
12
|
+
@skills = @skills.select { |s|
|
|
13
|
+
[s['name'], s['description'], Array(s['tags']).join(' ')].compact.join(' ').downcase.include?(needle)
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@sort = params[:sort].to_s
|
|
18
|
+
if @sort == 'used'
|
|
19
|
+
# Most-used first; file/builtin records (no counter) sink to the bottom alphabetically.
|
|
20
|
+
@skills = @skills.sort_by { |s| [-(s['use_count'].to_i), s['name'].to_s.downcase] }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
@versions = @skill.versions if @skill.is_a?(RailsConsoleAi::Skill)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def new
|
|
29
|
+
@skill = Skill.new(content: new_skill_template)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create
|
|
33
|
+
@skill = Skill.new
|
|
34
|
+
attrs = skill_params
|
|
35
|
+
begin
|
|
36
|
+
@skill.update_with_version!(
|
|
37
|
+
attrs,
|
|
38
|
+
edited_by: edited_by_param,
|
|
39
|
+
change_note: params[:change_note].presence
|
|
40
|
+
)
|
|
41
|
+
redirect_to skill_path(@skill), notice: 'Skill created.'
|
|
42
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
43
|
+
flash.now[:alert] = e.message
|
|
44
|
+
render :new
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def edit
|
|
49
|
+
redirect_to skills_path, alert: file_skill_message and return unless @skill.is_a?(RailsConsoleAi::Skill)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def update
|
|
53
|
+
redirect_to skills_path, alert: file_skill_message and return unless @skill.is_a?(RailsConsoleAi::Skill)
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
@skill.update_with_version!(
|
|
57
|
+
skill_params,
|
|
58
|
+
edited_by: edited_by_param,
|
|
59
|
+
change_note: params[:change_note].presence
|
|
60
|
+
)
|
|
61
|
+
redirect_to skill_path(@skill), notice: 'Skill updated.'
|
|
62
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
63
|
+
flash.now[:alert] = e.message
|
|
64
|
+
render :edit
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def destroy
|
|
69
|
+
if @skill.is_a?(RailsConsoleAi::Skill)
|
|
70
|
+
@skill.destroy
|
|
71
|
+
redirect_to skills_path, notice: 'Skill deleted. Past versions remain in history.'
|
|
72
|
+
else
|
|
73
|
+
redirect_to skills_path, alert: file_skill_message
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def approve
|
|
78
|
+
redirect_to skills_path, alert: file_skill_message and return unless @skill.is_a?(RailsConsoleAi::Skill)
|
|
79
|
+
|
|
80
|
+
approver = params[:approved_by].presence ||
|
|
81
|
+
(request.respond_to?(:remote_user) && request.remote_user.presence) ||
|
|
82
|
+
'web'
|
|
83
|
+
|
|
84
|
+
if @skill.approved?
|
|
85
|
+
redirect_to skill_path(@skill), notice: 'Skill is already approved.'
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
@skill.approve!(approved_by: approver)
|
|
91
|
+
redirect_to skill_path(@skill), notice: "Approved by #{approver}. The AI can now activate this skill."
|
|
92
|
+
rescue ArgumentError, ActiveRecord::RecordInvalid => e
|
|
93
|
+
redirect_to skill_path(@skill), alert: "Could not approve: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# GET /skills/diff?skill_id=&from=&to=
|
|
98
|
+
def diff
|
|
99
|
+
@skill = Skill.find(params[:skill_id])
|
|
100
|
+
@from = @skill.versions.find(params[:from])
|
|
101
|
+
@to = params[:to].present? ? @skill.versions.find(params[:to]) : nil
|
|
102
|
+
# If `to` is omitted, diff against the current skill.
|
|
103
|
+
@to_label = @to ? "Version ##{@to.id}" : 'Current'
|
|
104
|
+
@to_content = @to ? @to.content : @skill.content
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def load_skill
|
|
110
|
+
# /skills/:id supports DB ids and file slugs/names. For DB-sourced records we
|
|
111
|
+
# always return the AR record so write actions (update/destroy/approve) can
|
|
112
|
+
# operate on it; for file-sourced records we return the loaded Hash (view-only).
|
|
113
|
+
if params[:id].to_s =~ /\A\d+\z/
|
|
114
|
+
@skill = Skill.find(params[:id])
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Non-numeric :id — could be a DB-backed name/slug OR a file-only name.
|
|
119
|
+
# Try the DB by name first; fall back to the union (which surfaces file skills).
|
|
120
|
+
ar = Skill.where('LOWER(name) = ?', params[:id].to_s.downcase).first
|
|
121
|
+
if ar.nil?
|
|
122
|
+
# Maybe the URL has a slugified name (spaces → hyphens, punctuation stripped).
|
|
123
|
+
ar = Skill.all.find { |s| slugify(s.name) == params[:id] }
|
|
124
|
+
end
|
|
125
|
+
if ar
|
|
126
|
+
@skill = ar
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
all = SkillLoader.new.load_all_skills
|
|
131
|
+
@skill = all.find { |s| slugify(s['name']) == params[:id] || s['name'] == params[:id] }
|
|
132
|
+
raise ActiveRecord::RecordNotFound, "Skill not found: #{params[:id]}" unless @skill
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def skill_params
|
|
136
|
+
{ content: params.require(:skill)[:content].to_s }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def edited_by_param
|
|
140
|
+
params[:edited_by].presence || 'web'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def new_skill_template
|
|
144
|
+
<<~MD
|
|
145
|
+
---
|
|
146
|
+
name:
|
|
147
|
+
description:
|
|
148
|
+
tags: []
|
|
149
|
+
bypass_guards_for_methods: []
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## When to use
|
|
153
|
+
|
|
154
|
+
## Recipe
|
|
155
|
+
|
|
156
|
+
## Notes
|
|
157
|
+
MD
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def slugify(name)
|
|
161
|
+
name.to_s.downcase.strip
|
|
162
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
163
|
+
.gsub(/[\s]+/, '-')
|
|
164
|
+
.gsub(/-+/, '-')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def file_skill_message
|
|
168
|
+
'This skill lives on disk under .rails_console_ai/skills/. Edit the file directly to change it.'
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module RailsConsoleAi
|
|
5
|
+
module DiffHelper
|
|
6
|
+
# Render a side-by-side line diff of two strings.
|
|
7
|
+
#
|
|
8
|
+
# Returns HTML-safe markup with <span class="diff-add"> and
|
|
9
|
+
# <span class="diff-del"> rows. Uses a tiny LCS-based algorithm so we
|
|
10
|
+
# avoid taking on the diff-lcs gem as a runtime dependency.
|
|
11
|
+
def render_text_diff(left, right, left_label: 'Before', right_label: 'After')
|
|
12
|
+
a = (left || '').split("\n", -1)
|
|
13
|
+
b = (right || '').split("\n", -1)
|
|
14
|
+
ops = diff_ops(a, b)
|
|
15
|
+
|
|
16
|
+
rows = []
|
|
17
|
+
ops.each do |op, l_line, r_line|
|
|
18
|
+
rows << render_diff_row(op, l_line, r_line)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
html = +%(<table class="diff-table">)
|
|
22
|
+
html << %(<thead><tr><th>#{ERB::Util.h(left_label)}</th><th>#{ERB::Util.h(right_label)}</th></tr></thead>)
|
|
23
|
+
html << %(<tbody>) << rows.join << %(</tbody>)
|
|
24
|
+
html << %(</table>)
|
|
25
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Convenience for diffing two tag arrays / hashes as JSON.
|
|
29
|
+
def render_json_diff(left_obj, right_obj, **opts)
|
|
30
|
+
render_text_diff(
|
|
31
|
+
JSON.pretty_generate(left_obj || []),
|
|
32
|
+
JSON.pretty_generate(right_obj || []),
|
|
33
|
+
**opts
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def render_diff_row(op, l_line, r_line)
|
|
40
|
+
l_cell = l_line ? %(<pre>#{ERB::Util.h(l_line)}</pre>) : ''
|
|
41
|
+
r_cell = r_line ? %(<pre>#{ERB::Util.h(r_line)}</pre>) : ''
|
|
42
|
+
case op
|
|
43
|
+
when :equal
|
|
44
|
+
%(<tr><td>#{l_cell}</td><td>#{r_cell}</td></tr>)
|
|
45
|
+
when :del
|
|
46
|
+
%(<tr><td class="diff-del">#{l_cell}</td><td></td></tr>)
|
|
47
|
+
when :add
|
|
48
|
+
%(<tr><td></td><td class="diff-add">#{r_cell}</td></tr>)
|
|
49
|
+
when :change
|
|
50
|
+
%(<tr><td class="diff-del">#{l_cell}</td><td class="diff-add">#{r_cell}</td></tr>)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns an array of [op, left_line, right_line] tuples using LCS.
|
|
55
|
+
# ops: :equal, :del, :add, :change.
|
|
56
|
+
def diff_ops(a, b)
|
|
57
|
+
lcs = lcs_table(a, b)
|
|
58
|
+
ops = []
|
|
59
|
+
i = a.length
|
|
60
|
+
j = b.length
|
|
61
|
+
while i > 0 && j > 0
|
|
62
|
+
if a[i - 1] == b[j - 1]
|
|
63
|
+
ops.unshift([:equal, a[i - 1], b[j - 1]])
|
|
64
|
+
i -= 1; j -= 1
|
|
65
|
+
elsif lcs[i - 1][j] >= lcs[i][j - 1]
|
|
66
|
+
ops.unshift([:del, a[i - 1], nil])
|
|
67
|
+
i -= 1
|
|
68
|
+
else
|
|
69
|
+
ops.unshift([:add, nil, b[j - 1]])
|
|
70
|
+
j -= 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
while i > 0
|
|
74
|
+
ops.unshift([:del, a[i - 1], nil])
|
|
75
|
+
i -= 1
|
|
76
|
+
end
|
|
77
|
+
while j > 0
|
|
78
|
+
ops.unshift([:add, nil, b[j - 1]])
|
|
79
|
+
j -= 1
|
|
80
|
+
end
|
|
81
|
+
# Collapse adjacent del+add (or add+del) into a single :change row for readability.
|
|
82
|
+
collapsed = []
|
|
83
|
+
idx = 0
|
|
84
|
+
while idx < ops.length
|
|
85
|
+
cur, nxt = ops[idx], ops[idx + 1]
|
|
86
|
+
if nxt && cur[0] == :del && nxt[0] == :add
|
|
87
|
+
collapsed << [:change, cur[1], nxt[2]]
|
|
88
|
+
idx += 2
|
|
89
|
+
elsif nxt && cur[0] == :add && nxt[0] == :del
|
|
90
|
+
collapsed << [:change, nxt[1], cur[2]]
|
|
91
|
+
idx += 2
|
|
92
|
+
else
|
|
93
|
+
collapsed << cur
|
|
94
|
+
idx += 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
collapsed
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def lcs_table(a, b)
|
|
101
|
+
table = Array.new(a.length + 1) { Array.new(b.length + 1, 0) }
|
|
102
|
+
(1..a.length).each do |i|
|
|
103
|
+
(1..b.length).each do |j|
|
|
104
|
+
table[i][j] = if a[i - 1] == b[j - 1]
|
|
105
|
+
table[i - 1][j - 1] + 1
|
|
106
|
+
else
|
|
107
|
+
[table[i - 1][j], table[i][j - 1]].max
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
table
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
require 'rails_console_ai/agent_loader'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class Agent < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'rails_console_ai_agents'
|
|
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::AgentVersion',
|
|
14
|
+
foreign_key: :agent_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
|
+
def parsed
|
|
39
|
+
@parsed ||= (RailsConsoleAi::AgentLoader.parse(content.to_s) || {})
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def content=(value)
|
|
43
|
+
@parsed = nil
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def description; parsed['description']; end
|
|
48
|
+
def body; parsed['body']; end
|
|
49
|
+
def max_rounds; parsed['max_rounds']; end
|
|
50
|
+
def model; parsed['model']; end
|
|
51
|
+
def tools; Array(parsed['tools']); end
|
|
52
|
+
|
|
53
|
+
def proposed?; status.to_s == STATUS_PROPOSED; end
|
|
54
|
+
def approved?; status.to_s == STATUS_APPROVED; end
|
|
55
|
+
|
|
56
|
+
def self.record_use!(id)
|
|
57
|
+
where(id: id).update_all([
|
|
58
|
+
'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
|
|
59
|
+
Time.now.utc
|
|
60
|
+
])
|
|
61
|
+
true
|
|
62
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
63
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi::Agent.record_use!(#{id.inspect}) failed: #{e.message}")
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_hash
|
|
68
|
+
{
|
|
69
|
+
'id' => id,
|
|
70
|
+
'name' => name,
|
|
71
|
+
'description' => description,
|
|
72
|
+
'body' => body,
|
|
73
|
+
'max_rounds' => max_rounds,
|
|
74
|
+
'model' => model,
|
|
75
|
+
'tools' => tools,
|
|
76
|
+
'content' => content,
|
|
77
|
+
'status' => status,
|
|
78
|
+
'approved_by' => approved_by,
|
|
79
|
+
'approved_at' => approved_at,
|
|
80
|
+
'use_count' => use_count,
|
|
81
|
+
'last_used_at' => last_used_at,
|
|
82
|
+
'source' => :db,
|
|
83
|
+
'updated_at' => updated_at
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
|
|
88
|
+
transaction do
|
|
89
|
+
assign_attributes(attrs)
|
|
90
|
+
|
|
91
|
+
if !preserve_approval && approved? && changes.key?('content')
|
|
92
|
+
self.status = STATUS_PROPOSED
|
|
93
|
+
self.approved_by = nil
|
|
94
|
+
self.approved_at = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
save!
|
|
98
|
+
RailsConsoleAi::AgentVersion.create!(
|
|
99
|
+
agent_id: id,
|
|
100
|
+
name: name,
|
|
101
|
+
content: content,
|
|
102
|
+
status: status,
|
|
103
|
+
edited_by: edited_by,
|
|
104
|
+
change_note: change_note
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def approve!(approved_by:)
|
|
111
|
+
raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
|
|
112
|
+
|
|
113
|
+
update_with_version!(
|
|
114
|
+
{
|
|
115
|
+
status: STATUS_APPROVED,
|
|
116
|
+
approved_by: approved_by,
|
|
117
|
+
approved_at: Time.now.utc
|
|
118
|
+
},
|
|
119
|
+
edited_by: approved_by,
|
|
120
|
+
change_note: "Approved by #{approved_by}",
|
|
121
|
+
preserve_approval: true
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def sync_name_from_content
|
|
128
|
+
return if content.to_s.strip.empty?
|
|
129
|
+
parsed_name = parsed['name'].to_s.strip
|
|
130
|
+
self.name = parsed_name unless parsed_name.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def content_parses
|
|
134
|
+
return if content.to_s.strip.empty?
|
|
135
|
+
hash = RailsConsoleAi::AgentLoader.parse(content.to_s)
|
|
136
|
+
if hash.nil?
|
|
137
|
+
errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
|
|
138
|
+
elsif hash['name'].to_s.strip.empty?
|
|
139
|
+
errors.add(:content, "frontmatter is missing a `name:` field")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'rails_console_ai/agent_loader'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class AgentVersion < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'rails_console_ai_agent_versions'
|
|
6
|
+
|
|
7
|
+
belongs_to :agent,
|
|
8
|
+
class_name: 'RailsConsoleAi::Agent',
|
|
9
|
+
foreign_key: :agent_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::AgentLoader.parse(content.to_s) || {})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def description; parsed['description']; end
|
|
29
|
+
def body; parsed['body']; end
|
|
30
|
+
def max_rounds; parsed['max_rounds']; end
|
|
31
|
+
def model; parsed['model']; end
|
|
32
|
+
def tools; Array(parsed['tools']); end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require 'rails_console_ai/tools/memory_tools'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class Memory < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'rails_console_ai_memories'
|
|
6
|
+
|
|
7
|
+
has_many :versions,
|
|
8
|
+
-> { order(created_at: :desc) },
|
|
9
|
+
class_name: 'RailsConsoleAi::MemoryVersion',
|
|
10
|
+
foreign_key: :memory_id,
|
|
11
|
+
dependent: :nullify
|
|
12
|
+
|
|
13
|
+
validates :content, presence: true
|
|
14
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
15
|
+
validate :content_parses
|
|
16
|
+
|
|
17
|
+
before_validation :sync_name_from_content
|
|
18
|
+
|
|
19
|
+
scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
|
|
20
|
+
|
|
21
|
+
def self.connection
|
|
22
|
+
klass = RailsConsoleAi.configuration.connection_class
|
|
23
|
+
if klass
|
|
24
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
25
|
+
klass.connection
|
|
26
|
+
else
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parsed
|
|
32
|
+
@parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def content=(value)
|
|
36
|
+
@parsed = nil
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Memories don't have a separate description vs body — the markdown body
|
|
41
|
+
# IS the memory. Parser exposes it under 'description'.
|
|
42
|
+
def description; parsed['description']; end
|
|
43
|
+
def tags; Array(parsed['tags']); end
|
|
44
|
+
|
|
45
|
+
def self.record_use!(id)
|
|
46
|
+
where(id: id).update_all([
|
|
47
|
+
'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
|
|
48
|
+
Time.now.utc
|
|
49
|
+
])
|
|
50
|
+
true
|
|
51
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
52
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi::Memory.record_use!(#{id.inspect}) failed: #{e.message}")
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_hash
|
|
57
|
+
{
|
|
58
|
+
'id' => id,
|
|
59
|
+
'name' => name,
|
|
60
|
+
'description' => description,
|
|
61
|
+
'tags' => tags,
|
|
62
|
+
'content' => content,
|
|
63
|
+
'use_count' => use_count,
|
|
64
|
+
'last_used_at' => last_used_at,
|
|
65
|
+
'source' => :db,
|
|
66
|
+
'updated_at' => updated_at
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def update_with_version!(attrs, edited_by: nil, change_note: nil)
|
|
71
|
+
transaction do
|
|
72
|
+
assign_attributes(attrs)
|
|
73
|
+
save!
|
|
74
|
+
RailsConsoleAi::MemoryVersion.create!(
|
|
75
|
+
memory_id: id,
|
|
76
|
+
name: name,
|
|
77
|
+
content: content,
|
|
78
|
+
edited_by: edited_by,
|
|
79
|
+
change_note: change_note
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def sync_name_from_content
|
|
88
|
+
return if content.to_s.strip.empty?
|
|
89
|
+
parsed_name = parsed['name'].to_s.strip
|
|
90
|
+
self.name = parsed_name unless parsed_name.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def content_parses
|
|
94
|
+
return if content.to_s.strip.empty?
|
|
95
|
+
hash = RailsConsoleAi::Tools::MemoryTools.parse(content.to_s)
|
|
96
|
+
if hash.nil?
|
|
97
|
+
errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
|
|
98
|
+
elsif hash['name'].to_s.strip.empty?
|
|
99
|
+
errors.add(:content, "frontmatter is missing a `name:` field")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'rails_console_ai/tools/memory_tools'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class MemoryVersion < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'rails_console_ai_memory_versions'
|
|
6
|
+
|
|
7
|
+
belongs_to :memory,
|
|
8
|
+
class_name: 'RailsConsoleAi::Memory',
|
|
9
|
+
foreign_key: :memory_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::Tools::MemoryTools.parse(content.to_s) || {})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def description; parsed['description']; end
|
|
29
|
+
def tags; Array(parsed['tags']); end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -4,7 +4,7 @@ module RailsConsoleAi
|
|
|
4
4
|
|
|
5
5
|
validates :query, presence: true
|
|
6
6
|
validates :conversation, presence: true
|
|
7
|
-
validates :mode, presence: true, inclusion: { in: %w[one_shot interactive explain slack] }
|
|
7
|
+
validates :mode, presence: true, inclusion: { in: %w[one_shot interactive explain slack agent_api] }
|
|
8
8
|
|
|
9
9
|
scope :recent, -> { order(created_at: :desc) }
|
|
10
10
|
scope :named, ->(name) { where(name: name) }
|