rails_console_ai 0.30.0 → 0.32.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -26
  3. data/README.md +33 -11
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +2 -9
  5. data/app/controllers/rails_console_ai/agents_controller.rb +25 -48
  6. data/app/controllers/rails_console_ai/memories_controller.rb +35 -38
  7. data/app/controllers/rails_console_ai/memory_versions_controller.rb +2 -6
  8. data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +2 -8
  10. data/app/controllers/rails_console_ai/skills_controller.rb +19 -48
  11. data/app/models/rails_console_ai/agent.rb +33 -65
  12. data/app/models/rails_console_ai/agent_version.rb +8 -20
  13. data/app/models/rails_console_ai/memory.rb +67 -23
  14. data/app/models/rails_console_ai/memory_version.rb +5 -20
  15. data/app/models/rails_console_ai/skill.rb +55 -105
  16. data/app/models/rails_console_ai/skill_version.rb +7 -28
  17. data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
  18. data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
  19. data/app/views/rails_console_ai/agents/new.html.erb +0 -16
  20. data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
  21. data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
  22. data/app/views/rails_console_ai/memories/index.html.erb +13 -1
  23. data/app/views/rails_console_ai/memories/new.html.erb +0 -15
  24. data/app/views/rails_console_ai/memories/show.html.erb +23 -0
  25. data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
  26. data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
  27. data/app/views/rails_console_ai/skills/new.html.erb +0 -17
  28. data/config/routes.rb +3 -3
  29. data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
  30. data/lib/rails_console_ai/agent_loader.rb +18 -10
  31. data/lib/rails_console_ai/agent_runner.rb +66 -4
  32. data/lib/rails_console_ai/channel/console.rb +1 -1
  33. data/lib/rails_console_ai/configuration.rb +51 -33
  34. data/lib/rails_console_ai/conversation_engine.rb +3 -3
  35. data/lib/rails_console_ai/safety_guards.rb +36 -0
  36. data/lib/rails_console_ai/session_logger.rb +4 -0
  37. data/lib/rails_console_ai/skill_loader.rb +18 -9
  38. data/lib/rails_console_ai/slack_bot.rb +2 -2
  39. data/lib/rails_console_ai/storage/database_storage.rb +14 -20
  40. data/lib/rails_console_ai/sub_agent.rb +1 -1
  41. data/lib/rails_console_ai/tools/memory_tools.rb +37 -6
  42. data/lib/rails_console_ai/tools/registry.rb +18 -6
  43. data/lib/rails_console_ai/version.rb +1 -1
  44. data/lib/rails_console_ai.rb +92 -71
  45. metadata +1 -1
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/agent_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class Agent < ActiveRecord::Base
@@ -8,19 +8,18 @@ module RailsConsoleAi
8
8
  STATUS_APPROVED = 'approved'.freeze
9
9
  STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
10
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
11
  has_many :versions,
17
12
  -> { order(created_at: :desc) },
18
13
  class_name: 'RailsConsoleAi::AgentVersion',
19
14
  foreign_key: :agent_id,
20
15
  dependent: :nullify
21
16
 
17
+ validates :content, presence: true
22
18
  validates :name, presence: true, uniqueness: { case_sensitive: false }
23
- validates :status, inclusion: { in: STATUSES }, if: :has_attribute_status?
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validate :content_parses
21
+
22
+ before_validation :sync_name_from_content
24
23
 
25
24
  scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
26
25
  scope :approved, -> { where(status: STATUS_APPROVED) }
@@ -36,43 +35,25 @@ module RailsConsoleAi
36
35
  end
37
36
  end
38
37
 
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))
38
+ def parsed
39
+ @parsed ||= (RailsConsoleAi::AgentLoader.parse(content.to_s) || {})
47
40
  end
48
41
 
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
42
+ def content=(value)
43
+ @parsed = nil
44
+ super
53
45
  end
54
46
 
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
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
62
52
 
63
53
  def proposed?; status.to_s == STATUS_PROPOSED; end
64
54
  def approved?; status.to_s == STATUS_APPROVED; end
65
55
 
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
56
  def self.record_use!(id)
75
- return false unless connection.column_exists?(table_name, :use_count)
76
57
  where(id: id).update_all([
77
58
  'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
78
59
  Time.now.utc
@@ -92,6 +73,7 @@ module RailsConsoleAi
92
73
  'max_rounds' => max_rounds,
93
74
  'model' => model,
94
75
  'tools' => tools,
76
+ 'content' => content,
95
77
  'status' => status,
96
78
  'approved_by' => approved_by,
97
79
  'approved_at' => approved_at,
@@ -102,33 +84,11 @@ module RailsConsoleAi
102
84
  }
103
85
  end
104
86
 
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
87
  def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
128
88
  transaction do
129
89
  assign_attributes(attrs)
130
90
 
131
- if !preserve_approval && approved? && content_dirty?
91
+ if !preserve_approval && approved? && changes.key?('content')
132
92
  self.status = STATUS_PROPOSED
133
93
  self.approved_by = nil
134
94
  self.approved_at = nil
@@ -138,11 +98,7 @@ module RailsConsoleAi
138
98
  RailsConsoleAi::AgentVersion.create!(
139
99
  agent_id: id,
140
100
  name: name,
141
- description: description,
142
- body: body,
143
- max_rounds: max_rounds,
144
- model: model,
145
- tools: tools,
101
+ content: content,
146
102
  status: status,
147
103
  edited_by: edited_by,
148
104
  change_note: change_note
@@ -168,8 +124,20 @@ module RailsConsoleAi
168
124
 
169
125
  private
170
126
 
171
- def content_dirty?
172
- CONTENT_ATTRIBUTES.any? { |a| changes.key?(a) }
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
173
141
  end
174
142
  end
175
143
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/agent_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class AgentVersion < ActiveRecord::Base
@@ -21,26 +21,14 @@ module RailsConsoleAi
21
21
  end
22
22
  end
23
23
 
24
- def tools
25
- decode_json_array(read_attribute(:tools))
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::AgentLoader.parse(content.to_s) || {})
26
26
  end
27
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
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
45
33
  end
46
34
  end
@@ -1,18 +1,29 @@
1
- require 'json'
1
+ require 'rails_console_ai/tools/memory_tools'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class Memory < ActiveRecord::Base
5
5
  self.table_name = 'rails_console_ai_memories'
6
6
 
7
+ STATUS_PROPOSED = 'proposed'.freeze
8
+ STATUS_APPROVED = 'approved'.freeze
9
+ STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
10
+
7
11
  has_many :versions,
8
12
  -> { order(created_at: :desc) },
9
13
  class_name: 'RailsConsoleAi::MemoryVersion',
10
14
  foreign_key: :memory_id,
11
15
  dependent: :nullify
12
16
 
17
+ validates :content, presence: true
13
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
14
23
 
15
24
  scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
25
+ scope :approved, -> { where(status: STATUS_APPROVED) }
26
+ scope :proposed, -> { where(status: STATUS_PROPOSED) }
16
27
 
17
28
  def self.connection
18
29
  klass = RailsConsoleAi.configuration.connection_class
@@ -24,24 +35,24 @@ module RailsConsoleAi
24
35
  end
25
36
  end
26
37
 
27
- def tags
28
- decode_json_array(read_attribute(:tags))
38
+ def parsed
39
+ @parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
29
40
  end
30
41
 
31
- def tags=(value)
32
- write_attribute(:tags, encode_json_array(value))
42
+ def content=(value)
43
+ @parsed = nil
44
+ super
33
45
  end
34
46
 
35
- def use_count
36
- has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
37
- end
47
+ # Memories don't have a separate description vs body — the markdown body
48
+ # IS the memory. Parser exposes it under 'description'.
49
+ def description; parsed['description']; end
50
+ def tags; Array(parsed['tags']); end
38
51
 
39
- def last_used_at
40
- has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
41
- end
52
+ def proposed?; status.to_s == STATUS_PROPOSED; end
53
+ def approved?; status.to_s == STATUS_APPROVED; end
42
54
 
43
55
  def self.record_use!(id)
44
- return false unless connection.column_exists?(table_name, :use_count)
45
56
  where(id: id).update_all([
46
57
  'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
47
58
  Time.now.utc
@@ -58,6 +69,10 @@ module RailsConsoleAi
58
69
  'name' => name,
59
70
  'description' => description,
60
71
  'tags' => tags,
72
+ 'content' => content,
73
+ 'status' => status,
74
+ 'approved_by' => approved_by,
75
+ 'approved_at' => approved_at,
61
76
  'use_count' => use_count,
62
77
  'last_used_at' => last_used_at,
63
78
  'source' => :db,
@@ -65,15 +80,25 @@ module RailsConsoleAi
65
80
  }
66
81
  end
67
82
 
68
- def update_with_version!(attrs, edited_by: nil, change_note: nil)
83
+ # Assigns attrs, saves, and records one MemoryVersion snapshot.
84
+ # Any change to `content` reverts approval back to "proposed" unless
85
+ # `preserve_approval: true` is passed (approve! does this).
86
+ def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
69
87
  transaction do
70
88
  assign_attributes(attrs)
89
+
90
+ if !preserve_approval && approved? && changes.key?('content')
91
+ self.status = STATUS_PROPOSED
92
+ self.approved_by = nil
93
+ self.approved_at = nil
94
+ end
95
+
71
96
  save!
72
97
  RailsConsoleAi::MemoryVersion.create!(
73
98
  memory_id: id,
74
99
  name: name,
75
- description: description,
76
- tags: tags,
100
+ content: content,
101
+ status: status,
77
102
  edited_by: edited_by,
78
103
  change_note: change_note
79
104
  )
@@ -81,18 +106,37 @@ module RailsConsoleAi
81
106
  self
82
107
  end
83
108
 
109
+ def approve!(approved_by:)
110
+ raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
111
+
112
+ update_with_version!(
113
+ {
114
+ status: STATUS_APPROVED,
115
+ approved_by: approved_by,
116
+ approved_at: Time.now.utc
117
+ },
118
+ edited_by: approved_by,
119
+ change_note: "Approved by #{approved_by}",
120
+ preserve_approval: true
121
+ )
122
+ end
123
+
84
124
  private
85
125
 
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
- []
126
+ def sync_name_from_content
127
+ return if content.to_s.strip.empty?
128
+ parsed_name = parsed['name'].to_s.strip
129
+ self.name = parsed_name unless parsed_name.empty?
92
130
  end
93
131
 
94
- def encode_json_array(value)
95
- JSON.dump(Array(value))
132
+ def content_parses
133
+ return if content.to_s.strip.empty?
134
+ hash = RailsConsoleAi::Tools::MemoryTools.parse(content.to_s)
135
+ if hash.nil?
136
+ errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
137
+ elsif hash['name'].to_s.strip.empty?
138
+ errors.add(:content, "frontmatter is missing a `name:` field")
139
+ end
96
140
  end
97
141
  end
98
142
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/tools/memory_tools'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class MemoryVersion < ActiveRecord::Base
@@ -21,26 +21,11 @@ module RailsConsoleAi
21
21
  end
22
22
  end
23
23
 
24
- def tags
25
- decode_json_array(read_attribute(:tags))
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
26
26
  end
27
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
28
+ def description; parsed['description']; end
29
+ def tags; Array(parsed['tags']); end
45
30
  end
46
31
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/skill_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class Skill < ActiveRecord::Base
@@ -8,19 +8,18 @@ module RailsConsoleAi
8
8
  STATUS_APPROVED = 'approved'.freeze
9
9
  STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
10
10
 
11
- # Attributes that, if changed, invalidate the current approval and revert
12
- # the skill 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 tags bypass_guards_for_methods].freeze
15
-
16
11
  has_many :versions,
17
12
  -> { order(created_at: :desc) },
18
13
  class_name: 'RailsConsoleAi::SkillVersion',
19
14
  foreign_key: :skill_id,
20
15
  dependent: :nullify
21
16
 
17
+ validates :content, presence: true
22
18
  validates :name, presence: true, uniqueness: { case_sensitive: false }
23
- validates :status, inclusion: { in: STATUSES }, if: :has_attribute_status?
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validate :content_parses
21
+
22
+ before_validation :sync_name_from_content
24
23
 
25
24
  scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
26
25
  scope :approved, -> { where(status: STATUS_APPROVED) }
@@ -36,42 +35,38 @@ module RailsConsoleAi
36
35
  end
37
36
  end
38
37
 
39
- # Manual JSON accessors keep us off Rails-version-specific `serialize` syntax
40
- # (positional coder in Rails 5–6, keyword coder in Rails 7+).
41
- def tags
42
- decode_json_array(read_attribute(:tags))
43
- end
44
-
45
- def tags=(value)
46
- write_attribute(:tags, encode_json_array(value))
47
- end
48
-
49
- def bypass_guards_for_methods
50
- decode_json_array(read_attribute(:bypass_guards_for_methods))
51
- end
52
-
53
- def bypass_guards_for_methods=(value)
54
- write_attribute(:bypass_guards_for_methods, encode_json_array(value))
55
- end
56
-
57
- # Defensive accessors — if `ai_db_migrate` hasn't been run yet, the status
58
- # / approval columns may be missing on an older table. Return safe defaults
59
- # instead of blowing up with NameError.
60
- def status
61
- has_attribute_status? ? read_attribute(:status) : STATUS_PROPOSED
38
+ # Parsed view of the raw markdown content. Memoized per-instance and cleared
39
+ # on assignment. Returns {} for invalid content so callers don't need to nil-guard.
40
+ def parsed
41
+ @parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
62
42
  end
63
43
 
64
- def approved_by
65
- has_attribute?(:approved_by) ? read_attribute(:approved_by) : nil
44
+ def content=(value)
45
+ @parsed = nil
46
+ super
66
47
  end
67
48
 
68
- def approved_at
69
- has_attribute?(:approved_at) ? read_attribute(:approved_at) : nil
70
- end
49
+ def description; parsed['description']; end
50
+ def body; parsed['body']; end
51
+ def tags; Array(parsed['tags']); end
52
+ def bypass_guards_for_methods; Array(parsed['bypass_guards_for_methods']); end
71
53
 
72
54
  def proposed?; status.to_s == STATUS_PROPOSED; end
73
55
  def approved?; status.to_s == STATUS_APPROVED; end
74
56
 
57
+ # Atomically bump use_count + last_used_at without firing callbacks /
58
+ # validations / updated_at. Safe to call from concurrent AI tool calls.
59
+ def self.record_use!(id)
60
+ where(id: id).update_all([
61
+ 'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
62
+ Time.now.utc
63
+ ])
64
+ true
65
+ rescue ::ActiveRecord::ActiveRecordError => e
66
+ RailsConsoleAi.logger.warn("RailsConsoleAi::Skill.record_use!(#{id.inspect}) failed: #{e.message}")
67
+ false
68
+ end
69
+
75
70
  def to_hash
76
71
  {
77
72
  'id' => id,
@@ -80,6 +75,7 @@ module RailsConsoleAi
80
75
  'body' => body,
81
76
  'tags' => tags,
82
77
  'bypass_guards_for_methods' => bypass_guards_for_methods,
78
+ 'content' => content,
83
79
  'status' => status,
84
80
  'approved_by' => approved_by,
85
81
  'approved_at' => approved_at,
@@ -90,66 +86,14 @@ module RailsConsoleAi
90
86
  }
91
87
  end
92
88
 
93
- def has_attribute_status?
94
- has_attribute?(:status)
95
- end
96
-
97
- # Atomically bump use_count + last_used_at without firing callbacks /
98
- # validations / updated_at. Safe to call from concurrent AI tool calls.
99
- # No-op (returns false) if the table doesn't have the columns yet — that
100
- # keeps older installs working until they run ai_db_migrate.
101
- def self.record_use!(id)
102
- return false unless connection.column_exists?(table_name, :use_count)
103
- where(id: id).update_all([
104
- 'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
105
- Time.now.utc
106
- ])
107
- true
108
- rescue ::ActiveRecord::ActiveRecordError => e
109
- RailsConsoleAi.logger.warn("RailsConsoleAi::Skill.record_use!(#{id.inspect}) failed: #{e.message}")
110
- false
111
- end
112
-
113
- def use_count
114
- has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
115
- end
116
-
117
- def last_used_at
118
- has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
119
- end
120
-
121
- def self.decode_json_array(raw)
122
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
123
- return raw if raw.is_a?(Array)
124
- JSON.parse(raw)
125
- rescue JSON::ParserError
126
- []
127
- end
128
-
129
- def self.encode_json_array(value)
130
- JSON.dump(Array(value))
131
- end
132
-
133
- def decode_json_array(raw)
134
- self.class.decode_json_array(raw)
135
- end
136
-
137
- def encode_json_array(value)
138
- self.class.encode_json_array(value)
139
- end
140
-
141
- # Assigns attrs, saves, and records one SkillVersion snapshot of the post-save state.
142
- # Every save produces exactly one version row, so the version log is a complete history
143
- # including the current state (the most recent version mirrors `self`).
144
- #
145
- # If `preserve_approval` is false (the default), any change to a content attribute
146
- # reverts the skill back to "proposed" and clears the approver. Pass true from the
147
- # approve! flow so approval doesn't reset itself.
89
+ # Assigns attrs, saves, and records one SkillVersion snapshot.
90
+ # Any change to `content` reverts approval back to "proposed" unless
91
+ # `preserve_approval: true` is passed (approve! does this).
148
92
  def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
149
93
  transaction do
150
94
  assign_attributes(attrs)
151
95
 
152
- if !preserve_approval && approved? && content_dirty?
96
+ if !preserve_approval && approved? && changes.key?('content')
153
97
  self.status = STATUS_PROPOSED
154
98
  self.approved_by = nil
155
99
  self.approved_at = nil
@@ -157,22 +101,17 @@ module RailsConsoleAi
157
101
 
158
102
  save!
159
103
  RailsConsoleAi::SkillVersion.create!(
160
- skill_id: id,
161
- name: name,
162
- description: description,
163
- body: body,
164
- tags: tags,
165
- bypass_guards_for_methods: bypass_guards_for_methods,
166
- status: status,
167
- edited_by: edited_by,
168
- change_note: change_note
104
+ skill_id: id,
105
+ name: name,
106
+ content: content,
107
+ status: status,
108
+ edited_by: edited_by,
109
+ change_note: change_note
169
110
  )
170
111
  end
171
112
  self
172
113
  end
173
114
 
174
- # Marks the current head as approved. Logs a version row with the approver name
175
- # so the audit trail captures the approval moment.
176
115
  def approve!(approved_by:)
177
116
  raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
178
117
 
@@ -190,9 +129,20 @@ module RailsConsoleAi
190
129
 
191
130
  private
192
131
 
193
- # Did any content-bearing attribute change in this assign_attributes pass?
194
- def content_dirty?
195
- CONTENT_ATTRIBUTES.any? { |a| changes.key?(a) }
132
+ def sync_name_from_content
133
+ return if content.to_s.strip.empty?
134
+ parsed_name = parsed['name'].to_s.strip
135
+ self.name = parsed_name unless parsed_name.empty?
136
+ end
137
+
138
+ def content_parses
139
+ return if content.to_s.strip.empty? # presence validator handles blank
140
+ hash = RailsConsoleAi::SkillLoader.parse(content.to_s)
141
+ if hash.nil?
142
+ errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
143
+ elsif hash['name'].to_s.strip.empty?
144
+ errors.add(:content, "frontmatter is missing a `name:` field")
145
+ end
196
146
  end
197
147
  end
198
148
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/skill_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class SkillVersion < ActiveRecord::Base
@@ -21,34 +21,13 @@ module RailsConsoleAi
21
21
  end
22
22
  end
23
23
 
24
- def tags
25
- decode_json_array(read_attribute(:tags))
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
26
26
  end
27
27
 
28
- def tags=(value)
29
- write_attribute(:tags, encode_json_array(value))
30
- end
31
-
32
- def bypass_guards_for_methods
33
- decode_json_array(read_attribute(:bypass_guards_for_methods))
34
- end
35
-
36
- def bypass_guards_for_methods=(value)
37
- write_attribute(:bypass_guards_for_methods, encode_json_array(value))
38
- end
39
-
40
- private
41
-
42
- def decode_json_array(raw)
43
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
44
- return raw if raw.is_a?(Array)
45
- JSON.parse(raw)
46
- rescue JSON::ParserError
47
- []
48
- end
49
-
50
- def encode_json_array(value)
51
- JSON.dump(Array(value))
52
- end
28
+ def description; parsed['description']; end
29
+ def body; parsed['body']; end
30
+ def tags; Array(parsed['tags']); end
31
+ def bypass_guards_for_methods; Array(parsed['bypass_guards_for_methods']); end
53
32
  end
54
33
  end