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
|
@@ -0,0 +1,200 @@
|
|
|
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
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /skills/import — accepts a pasted .md blob in params[:content], parses
|
|
33
|
+
# YAML frontmatter + body, and re-renders `new` with the fields pre-populated.
|
|
34
|
+
# The user reviews + clicks Create skill to actually persist (normal proposed-
|
|
35
|
+
# status + version-row flow applies).
|
|
36
|
+
def import
|
|
37
|
+
content = params[:content].to_s
|
|
38
|
+
if content.strip.empty?
|
|
39
|
+
redirect_to new_skill_path, alert: 'Nothing to parse — paste the .md content into the box first.'
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
parsed = SkillLoader.parse(content)
|
|
44
|
+
if parsed.nil? || parsed['name'].to_s.strip.empty?
|
|
45
|
+
redirect_to new_skill_path,
|
|
46
|
+
alert: 'Could not parse. Expected YAML frontmatter (between `---` lines) with at least a `name` field, followed by a markdown body.'
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@skill = Skill.new(
|
|
51
|
+
name: parsed['name'],
|
|
52
|
+
description: parsed['description'],
|
|
53
|
+
body: parsed['body']
|
|
54
|
+
)
|
|
55
|
+
@skill.tags = Array(parsed['tags'])
|
|
56
|
+
@skill.bypass_guards_for_methods = Array(parsed['bypass_guards_for_methods'])
|
|
57
|
+
|
|
58
|
+
flash.now[:notice] = "Parsed \"#{parsed['name']}\" from pasted content. Review the fields below and click Create skill to save to the DB."
|
|
59
|
+
render :new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def create
|
|
63
|
+
@skill = Skill.new
|
|
64
|
+
attrs = skill_params
|
|
65
|
+
begin
|
|
66
|
+
@skill.update_with_version!(
|
|
67
|
+
attrs,
|
|
68
|
+
edited_by: edited_by_param,
|
|
69
|
+
change_note: params[:change_note].presence
|
|
70
|
+
)
|
|
71
|
+
redirect_to skill_path(@skill), notice: 'Skill created.'
|
|
72
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
73
|
+
flash.now[:alert] = e.message
|
|
74
|
+
render :new
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def edit
|
|
79
|
+
redirect_to skills_path, alert: file_skill_message and return unless @skill.is_a?(RailsConsoleAi::Skill)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def update
|
|
83
|
+
redirect_to skills_path, alert: file_skill_message and return unless @skill.is_a?(RailsConsoleAi::Skill)
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
@skill.update_with_version!(
|
|
87
|
+
skill_params,
|
|
88
|
+
edited_by: edited_by_param,
|
|
89
|
+
change_note: params[:change_note].presence
|
|
90
|
+
)
|
|
91
|
+
redirect_to skill_path(@skill), notice: 'Skill updated.'
|
|
92
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
93
|
+
flash.now[:alert] = e.message
|
|
94
|
+
render :edit
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def destroy
|
|
99
|
+
if @skill.is_a?(RailsConsoleAi::Skill)
|
|
100
|
+
@skill.destroy
|
|
101
|
+
redirect_to skills_path, notice: 'Skill deleted. Past versions remain in history.'
|
|
102
|
+
else
|
|
103
|
+
redirect_to skills_path, alert: file_skill_message
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def approve
|
|
108
|
+
redirect_to skills_path, alert: file_skill_message and return unless @skill.is_a?(RailsConsoleAi::Skill)
|
|
109
|
+
|
|
110
|
+
approver = params[:approved_by].presence ||
|
|
111
|
+
(request.respond_to?(:remote_user) && request.remote_user.presence) ||
|
|
112
|
+
'web'
|
|
113
|
+
|
|
114
|
+
if @skill.approved?
|
|
115
|
+
redirect_to skill_path(@skill), notice: 'Skill is already approved.'
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
@skill.approve!(approved_by: approver)
|
|
121
|
+
redirect_to skill_path(@skill), notice: "Approved by #{approver}. The AI can now activate this skill."
|
|
122
|
+
rescue ArgumentError, ActiveRecord::RecordInvalid => e
|
|
123
|
+
redirect_to skill_path(@skill), alert: "Could not approve: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# GET /skills/diff?skill_id=&from=&to=
|
|
128
|
+
def diff
|
|
129
|
+
@skill = Skill.find(params[:skill_id])
|
|
130
|
+
@from = @skill.versions.find(params[:from])
|
|
131
|
+
@to = params[:to].present? ? @skill.versions.find(params[:to]) : nil
|
|
132
|
+
# If `to` is omitted, diff against the current skill.
|
|
133
|
+
@to_label = @to ? "Version ##{@to.id}" : 'Current'
|
|
134
|
+
@to_body = @to ? @to.body : @skill.body
|
|
135
|
+
@to_tags = @to ? Array(@to.tags) : Array(@skill.tags)
|
|
136
|
+
@to_bypass = @to ? Array(@to.bypass_guards_for_methods) : Array(@skill.bypass_guards_for_methods)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def load_skill
|
|
142
|
+
# /skills/:id supports DB ids and file slugs/names. For DB-sourced records we
|
|
143
|
+
# always return the AR record so write actions (update/destroy/approve) can
|
|
144
|
+
# operate on it; for file-sourced records we return the loaded Hash (view-only).
|
|
145
|
+
if params[:id].to_s =~ /\A\d+\z/
|
|
146
|
+
@skill = Skill.find(params[:id])
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Non-numeric :id — could be a DB-backed name/slug OR a file-only name.
|
|
151
|
+
# Try the DB by name first; fall back to the union (which surfaces file skills).
|
|
152
|
+
ar = Skill.where('LOWER(name) = ?', params[:id].to_s.downcase).first
|
|
153
|
+
if ar.nil?
|
|
154
|
+
# Maybe the URL has a slugified name (spaces → hyphens, punctuation stripped).
|
|
155
|
+
ar = Skill.all.find { |s| slugify(s.name) == params[:id] }
|
|
156
|
+
end
|
|
157
|
+
if ar
|
|
158
|
+
@skill = ar
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
all = SkillLoader.new.load_all_skills
|
|
163
|
+
@skill = all.find { |s| slugify(s['name']) == params[:id] || s['name'] == params[:id] }
|
|
164
|
+
raise ActiveRecord::RecordNotFound, "Skill not found: #{params[:id]}" unless @skill
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def skill_params
|
|
168
|
+
{
|
|
169
|
+
name: params.require(:skill)[:name],
|
|
170
|
+
description: params[:skill][:description],
|
|
171
|
+
body: params[:skill][:body],
|
|
172
|
+
tags: split_csv(params[:skill][:tags]),
|
|
173
|
+
bypass_guards_for_methods: split_lines(params[:skill][:bypass_guards_for_methods])
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def edited_by_param
|
|
178
|
+
params[:edited_by].presence || 'web'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def split_csv(str)
|
|
182
|
+
str.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def split_lines(str)
|
|
186
|
+
str.to_s.split(/[\r\n]+/).map(&:strip).reject(&:empty?)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def slugify(name)
|
|
190
|
+
name.to_s.downcase.strip
|
|
191
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
192
|
+
.gsub(/[\s]+/, '-')
|
|
193
|
+
.gsub(/-+/, '-')
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def file_skill_message
|
|
197
|
+
'This skill lives on disk under .rails_console_ai/skills/. Edit the file directly to change it.'
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
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,175 @@
|
|
|
1
|
+
require 'json'
|
|
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
|
+
# Attributes that, if changed, invalidate the current approval and revert
|
|
12
|
+
# the agent back to "proposed". Status / approver columns are excluded so
|
|
13
|
+
# that an explicit approve! call doesn't reset its own approval.
|
|
14
|
+
CONTENT_ATTRIBUTES = %w[name description body max_rounds model tools].freeze
|
|
15
|
+
|
|
16
|
+
has_many :versions,
|
|
17
|
+
-> { order(created_at: :desc) },
|
|
18
|
+
class_name: 'RailsConsoleAi::AgentVersion',
|
|
19
|
+
foreign_key: :agent_id,
|
|
20
|
+
dependent: :nullify
|
|
21
|
+
|
|
22
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
23
|
+
validates :status, inclusion: { in: STATUSES }, if: :has_attribute_status?
|
|
24
|
+
|
|
25
|
+
scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
|
|
26
|
+
scope :approved, -> { where(status: STATUS_APPROVED) }
|
|
27
|
+
scope :proposed, -> { where(status: STATUS_PROPOSED) }
|
|
28
|
+
|
|
29
|
+
def self.connection
|
|
30
|
+
klass = RailsConsoleAi.configuration.connection_class
|
|
31
|
+
if klass
|
|
32
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
33
|
+
klass.connection
|
|
34
|
+
else
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Manual JSON accessor for `tools` — same approach we use for skill tags,
|
|
40
|
+
# avoids Rails-version-specific `serialize` API.
|
|
41
|
+
def tools
|
|
42
|
+
decode_json_array(read_attribute(:tools))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tools=(value)
|
|
46
|
+
write_attribute(:tools, encode_json_array(value))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Defensive accessors — if `ai_db_migrate` hasn't been run yet, the status
|
|
50
|
+
# / approval columns may be missing on an older table.
|
|
51
|
+
def status
|
|
52
|
+
has_attribute_status? ? read_attribute(:status) : STATUS_PROPOSED
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def approved_by
|
|
56
|
+
has_attribute?(:approved_by) ? read_attribute(:approved_by) : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def approved_at
|
|
60
|
+
has_attribute?(:approved_at) ? read_attribute(:approved_at) : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def proposed?; status.to_s == STATUS_PROPOSED; end
|
|
64
|
+
def approved?; status.to_s == STATUS_APPROVED; end
|
|
65
|
+
|
|
66
|
+
def use_count
|
|
67
|
+
has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def last_used_at
|
|
71
|
+
has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.record_use!(id)
|
|
75
|
+
return false unless connection.column_exists?(table_name, :use_count)
|
|
76
|
+
where(id: id).update_all([
|
|
77
|
+
'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
|
|
78
|
+
Time.now.utc
|
|
79
|
+
])
|
|
80
|
+
true
|
|
81
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
82
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi::Agent.record_use!(#{id.inspect}) failed: #{e.message}")
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_hash
|
|
87
|
+
{
|
|
88
|
+
'id' => id,
|
|
89
|
+
'name' => name,
|
|
90
|
+
'description' => description,
|
|
91
|
+
'body' => body,
|
|
92
|
+
'max_rounds' => max_rounds,
|
|
93
|
+
'model' => model,
|
|
94
|
+
'tools' => tools,
|
|
95
|
+
'status' => status,
|
|
96
|
+
'approved_by' => approved_by,
|
|
97
|
+
'approved_at' => approved_at,
|
|
98
|
+
'use_count' => use_count,
|
|
99
|
+
'last_used_at' => last_used_at,
|
|
100
|
+
'source' => :db,
|
|
101
|
+
'updated_at' => updated_at
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def has_attribute_status?
|
|
106
|
+
has_attribute?(:status)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.decode_json_array(raw)
|
|
110
|
+
return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
111
|
+
return raw if raw.is_a?(Array)
|
|
112
|
+
JSON.parse(raw)
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.encode_json_array(value)
|
|
118
|
+
JSON.dump(Array(value))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def decode_json_array(raw); self.class.decode_json_array(raw); end
|
|
122
|
+
def encode_json_array(value); self.class.encode_json_array(value); end
|
|
123
|
+
|
|
124
|
+
# Assigns attrs, saves, and records one AgentVersion snapshot of the post-save state.
|
|
125
|
+
# If `preserve_approval` is false (the default), any change to a content attribute
|
|
126
|
+
# reverts the agent back to "proposed" and clears the approver.
|
|
127
|
+
def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
|
|
128
|
+
transaction do
|
|
129
|
+
assign_attributes(attrs)
|
|
130
|
+
|
|
131
|
+
if !preserve_approval && approved? && content_dirty?
|
|
132
|
+
self.status = STATUS_PROPOSED
|
|
133
|
+
self.approved_by = nil
|
|
134
|
+
self.approved_at = nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
save!
|
|
138
|
+
RailsConsoleAi::AgentVersion.create!(
|
|
139
|
+
agent_id: id,
|
|
140
|
+
name: name,
|
|
141
|
+
description: description,
|
|
142
|
+
body: body,
|
|
143
|
+
max_rounds: max_rounds,
|
|
144
|
+
model: model,
|
|
145
|
+
tools: tools,
|
|
146
|
+
status: status,
|
|
147
|
+
edited_by: edited_by,
|
|
148
|
+
change_note: change_note
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def approve!(approved_by:)
|
|
155
|
+
raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
|
|
156
|
+
|
|
157
|
+
update_with_version!(
|
|
158
|
+
{
|
|
159
|
+
status: STATUS_APPROVED,
|
|
160
|
+
approved_by: approved_by,
|
|
161
|
+
approved_at: Time.now.utc
|
|
162
|
+
},
|
|
163
|
+
edited_by: approved_by,
|
|
164
|
+
change_note: "Approved by #{approved_by}",
|
|
165
|
+
preserve_approval: true
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def content_dirty?
|
|
172
|
+
CONTENT_ATTRIBUTES.any? { |a| changes.key?(a) }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'json'
|
|
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 tools
|
|
25
|
+
decode_json_array(read_attribute(:tools))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tools=(value)
|
|
29
|
+
write_attribute(:tools, encode_json_array(value))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def decode_json_array(raw)
|
|
35
|
+
return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
36
|
+
return raw if raw.is_a?(Array)
|
|
37
|
+
JSON.parse(raw)
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def encode_json_array(value)
|
|
43
|
+
JSON.dump(Array(value))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require 'json'
|
|
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 :name, presence: true, uniqueness: { case_sensitive: false }
|
|
14
|
+
|
|
15
|
+
scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
|
|
16
|
+
|
|
17
|
+
def self.connection
|
|
18
|
+
klass = RailsConsoleAi.configuration.connection_class
|
|
19
|
+
if klass
|
|
20
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
21
|
+
klass.connection
|
|
22
|
+
else
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tags
|
|
28
|
+
decode_json_array(read_attribute(:tags))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tags=(value)
|
|
32
|
+
write_attribute(:tags, encode_json_array(value))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def use_count
|
|
36
|
+
has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def last_used_at
|
|
40
|
+
has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.record_use!(id)
|
|
44
|
+
return false unless connection.column_exists?(table_name, :use_count)
|
|
45
|
+
where(id: id).update_all([
|
|
46
|
+
'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
|
|
47
|
+
Time.now.utc
|
|
48
|
+
])
|
|
49
|
+
true
|
|
50
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
51
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi::Memory.record_use!(#{id.inspect}) failed: #{e.message}")
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_hash
|
|
56
|
+
{
|
|
57
|
+
'id' => id,
|
|
58
|
+
'name' => name,
|
|
59
|
+
'description' => description,
|
|
60
|
+
'tags' => tags,
|
|
61
|
+
'use_count' => use_count,
|
|
62
|
+
'last_used_at' => last_used_at,
|
|
63
|
+
'source' => :db,
|
|
64
|
+
'updated_at' => updated_at
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update_with_version!(attrs, edited_by: nil, change_note: nil)
|
|
69
|
+
transaction do
|
|
70
|
+
assign_attributes(attrs)
|
|
71
|
+
save!
|
|
72
|
+
RailsConsoleAi::MemoryVersion.create!(
|
|
73
|
+
memory_id: id,
|
|
74
|
+
name: name,
|
|
75
|
+
description: description,
|
|
76
|
+
tags: tags,
|
|
77
|
+
edited_by: edited_by,
|
|
78
|
+
change_note: change_note
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def decode_json_array(raw)
|
|
87
|
+
return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
88
|
+
return raw if raw.is_a?(Array)
|
|
89
|
+
JSON.parse(raw)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def encode_json_array(value)
|
|
95
|
+
JSON.dump(Array(value))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'json'
|
|
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 tags
|
|
25
|
+
decode_json_array(read_attribute(:tags))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tags=(value)
|
|
29
|
+
write_attribute(:tags, encode_json_array(value))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def decode_json_array(raw)
|
|
35
|
+
return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
36
|
+
return raw if raw.is_a?(Array)
|
|
37
|
+
JSON.parse(raw)
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def encode_json_array(value)
|
|
43
|
+
JSON.dump(Array(value))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
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) }
|