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,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) }