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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -26
- data/README.md +33 -11
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +2 -9
- data/app/controllers/rails_console_ai/agents_controller.rb +25 -48
- data/app/controllers/rails_console_ai/memories_controller.rb +35 -38
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +2 -6
- data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +2 -8
- data/app/controllers/rails_console_ai/skills_controller.rb +19 -48
- data/app/models/rails_console_ai/agent.rb +33 -65
- data/app/models/rails_console_ai/agent_version.rb +8 -20
- data/app/models/rails_console_ai/memory.rb +67 -23
- data/app/models/rails_console_ai/memory_version.rb +5 -20
- data/app/models/rails_console_ai/skill.rb +55 -105
- data/app/models/rails_console_ai/skill_version.rb +7 -28
- data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
- data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
- data/app/views/rails_console_ai/agents/new.html.erb +0 -16
- data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
- data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
- data/app/views/rails_console_ai/memories/index.html.erb +13 -1
- data/app/views/rails_console_ai/memories/new.html.erb +0 -15
- data/app/views/rails_console_ai/memories/show.html.erb +23 -0
- data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
- data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
- data/app/views/rails_console_ai/skills/new.html.erb +0 -17
- data/config/routes.rb +3 -3
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
- data/lib/rails_console_ai/agent_loader.rb +18 -10
- data/lib/rails_console_ai/agent_runner.rb +66 -4
- data/lib/rails_console_ai/channel/console.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +51 -33
- data/lib/rails_console_ai/conversation_engine.rb +3 -3
- data/lib/rails_console_ai/safety_guards.rb +36 -0
- data/lib/rails_console_ai/session_logger.rb +4 -0
- data/lib/rails_console_ai/skill_loader.rb +18 -9
- data/lib/rails_console_ai/slack_bot.rb +2 -2
- data/lib/rails_console_ai/storage/database_storage.rb +14 -20
- data/lib/rails_console_ai/sub_agent.rb +1 -1
- data/lib/rails_console_ai/tools/memory_tools.rb +37 -6
- data/lib/rails_console_ai/tools/registry.rb +18 -6
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +92 -71
- metadata +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require '
|
|
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 }
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def
|
|
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? &&
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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 '
|
|
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
|
|
25
|
-
|
|
24
|
+
def parsed
|
|
25
|
+
@parsed ||= (RailsConsoleAi::AgentLoader.parse(content.to_s) || {})
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
|
|
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 '
|
|
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
|
|
28
|
-
|
|
38
|
+
def parsed
|
|
39
|
+
@parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
|
|
29
40
|
end
|
|
30
41
|
|
|
31
|
-
def
|
|
32
|
-
|
|
42
|
+
def content=(value)
|
|
43
|
+
@parsed = nil
|
|
44
|
+
super
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
95
|
-
|
|
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 '
|
|
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
|
|
25
|
-
|
|
24
|
+
def parsed
|
|
25
|
+
@parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
|
|
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 '
|
|
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 }
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
def
|
|
42
|
-
|
|
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
|
|
65
|
-
|
|
44
|
+
def content=(value)
|
|
45
|
+
@parsed = nil
|
|
46
|
+
super
|
|
66
47
|
end
|
|
67
48
|
|
|
68
|
-
def
|
|
69
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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? &&
|
|
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:
|
|
161
|
-
name:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 '
|
|
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
|
|
25
|
-
|
|
24
|
+
def parsed
|
|
25
|
+
@parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
|
|
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
|