rails_console_ai 0.28.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -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/channel/slack.rb +33 -0
  49. data/lib/rails_console_ai/channel/sub_agent.rb +12 -0
  50. data/lib/rails_console_ai/conversation_engine.rb +50 -13
  51. data/lib/rails_console_ai/session_logger.rb +6 -0
  52. data/lib/rails_console_ai/skill_loader.rb +119 -27
  53. data/lib/rails_console_ai/slack_bot.rb +8 -0
  54. data/lib/rails_console_ai/storage/database_storage.rb +201 -0
  55. data/lib/rails_console_ai/sub_agent.rb +25 -0
  56. data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
  57. data/lib/rails_console_ai/tools/registry.rb +99 -8
  58. data/lib/rails_console_ai/version.rb +1 -1
  59. data/lib/rails_console_ai.rb +256 -0
  60. data/lib/tasks/rails_console_ai.rake +7 -0
  61. 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) }