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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +65 -0
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +29 -0
- data/app/controllers/rails_console_ai/agents_controller.rb +176 -0
- data/app/controllers/rails_console_ai/application_controller.rb +5 -0
- data/app/controllers/rails_console_ai/memories_controller.rb +136 -0
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +29 -0
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +29 -0
- data/app/controllers/rails_console_ai/skills_controller.rb +171 -0
- data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
- data/app/models/rails_console_ai/agent.rb +143 -0
- data/app/models/rails_console_ai/agent_version.rb +34 -0
- data/app/models/rails_console_ai/memory.rb +103 -0
- data/app/models/rails_console_ai/memory_version.rb +31 -0
- data/app/models/rails_console_ai/session.rb +1 -1
- data/app/models/rails_console_ai/skill.rb +148 -0
- data/app/models/rails_console_ai/skill_version.rb +33 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
- data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
- data/app/views/rails_console_ai/agents/_form.html.erb +40 -0
- data/app/views/rails_console_ai/agents/diff.html.erb +15 -0
- data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
- data/app/views/rails_console_ai/agents/index.html.erb +80 -0
- data/app/views/rails_console_ai/agents/new.html.erb +8 -0
- data/app/views/rails_console_ai/agents/show.html.erb +108 -0
- data/app/views/rails_console_ai/memories/_form.html.erb +29 -0
- data/app/views/rails_console_ai/memories/diff.html.erb +15 -0
- data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
- data/app/views/rails_console_ai/memories/index.html.erb +67 -0
- data/app/views/rails_console_ai/memories/new.html.erb +8 -0
- data/app/views/rails_console_ai/memories/show.html.erb +65 -0
- data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
- data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
- data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
- data/app/views/rails_console_ai/skills/_form.html.erb +43 -0
- data/app/views/rails_console_ai/skills/diff.html.erb +15 -0
- data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
- data/app/views/rails_console_ai/skills/index.html.erb +79 -0
- data/app/views/rails_console_ai/skills/new.html.erb +8 -0
- data/app/views/rails_console_ai/skills/show.html.erb +94 -0
- data/config/routes.rb +39 -0
- data/lib/rails_console_ai/agent_loader.rb +139 -43
- data/lib/rails_console_ai/agent_runner.rb +209 -0
- data/lib/rails_console_ai/channel/api.rb +139 -0
- data/lib/rails_console_ai/conversation_engine.rb +19 -13
- data/lib/rails_console_ai/session_logger.rb +10 -0
- data/lib/rails_console_ai/skill_loader.rb +130 -29
- data/lib/rails_console_ai/storage/database_storage.rb +195 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +110 -32
- data/lib/rails_console_ai/tools/registry.rb +99 -8
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +240 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +55 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
|
+
require 'rails_console_ai/storage/database_storage'
|
|
2
3
|
|
|
3
4
|
module RailsConsoleAi
|
|
4
5
|
class SkillLoader
|
|
@@ -8,16 +9,33 @@ module RailsConsoleAi
|
|
|
8
9
|
@storage = storage || RailsConsoleAi.storage
|
|
9
10
|
end
|
|
10
11
|
|
|
12
|
+
# Returns the union of DB-backed skills and file-backed skills.
|
|
13
|
+
# When the same name appears in both, the DB record wins and the file
|
|
14
|
+
# record is shadowed (but the file isn't touched).
|
|
15
|
+
#
|
|
16
|
+
# Includes proposed (unapproved) DB skills — they show up in the admin UI
|
|
17
|
+
# with a "PROPOSED" badge. The AI-facing surface (#skill_summaries, #find_skill)
|
|
18
|
+
# filters them out, so an unapproved skill can never be activated.
|
|
11
19
|
def load_all_skills
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[]
|
|
20
|
+
db = safe_load_db_skills
|
|
21
|
+
file = safe_load_file_skills
|
|
22
|
+
|
|
23
|
+
names = db.map { |s| s['name'].to_s.downcase }
|
|
24
|
+
file.reject! { |s| names.include?(s['name'].to_s.downcase) }
|
|
25
|
+
|
|
26
|
+
(db + file).sort_by { |s| s['name'].to_s.downcase }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Skills the AI is allowed to see / activate: approved DB skills + all file skills.
|
|
30
|
+
# File skills are considered pre-approved because they're git-tracked.
|
|
31
|
+
def load_activatable_skills
|
|
32
|
+
# Use string literal so this doesn't require the AR model to be autoloaded
|
|
33
|
+
# in environments that don't reference it.
|
|
34
|
+
load_all_skills.reject { |s| s['source'] == :db && s['status'] != 'approved' }
|
|
17
35
|
end
|
|
18
36
|
|
|
19
37
|
def skill_summaries
|
|
20
|
-
skills =
|
|
38
|
+
skills = load_activatable_skills
|
|
21
39
|
return nil if skills.empty?
|
|
22
40
|
|
|
23
41
|
skills.map { |s|
|
|
@@ -28,44 +46,70 @@ module RailsConsoleAi
|
|
|
28
46
|
end
|
|
29
47
|
|
|
30
48
|
def find_skill(name)
|
|
31
|
-
|
|
32
|
-
skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
|
|
49
|
+
load_activatable_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
|
|
33
50
|
end
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
frontmatter = {
|
|
40
|
-
'name' => name,
|
|
41
|
-
'description' => description,
|
|
42
|
-
'tags' => Array(tags)
|
|
43
|
-
}
|
|
44
|
-
bypasses = Array(bypass_guards_for_methods)
|
|
45
|
-
frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
|
|
52
|
+
# UI-facing: includes proposed skills too, with name-collision DB wins.
|
|
53
|
+
def find_any_skill(name)
|
|
54
|
+
load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
|
|
55
|
+
end
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
# target: :db (default) | :file
|
|
58
|
+
# Falls back to :file (with a notice in the return string) if DB tables aren't set up.
|
|
59
|
+
def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [], target: :db, edited_by: nil, change_note: nil)
|
|
60
|
+
target = (target || :db).to_sym
|
|
61
|
+
db_fell_back = false
|
|
62
|
+
if target == :db && !Storage::DatabaseStorage.available?
|
|
63
|
+
target = :file
|
|
64
|
+
db_fell_back = true
|
|
65
|
+
end
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
if target == :file
|
|
68
|
+
result = save_skill_to_file(
|
|
69
|
+
name: name, description: description, body: body,
|
|
70
|
+
tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
|
|
71
|
+
)
|
|
72
|
+
if db_fell_back
|
|
73
|
+
result += "\nNOTE: DB storage was requested but the rails_console_ai_skills table does not exist. " \
|
|
74
|
+
"Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
|
|
75
|
+
"Saved to a file instead."
|
|
76
|
+
end
|
|
77
|
+
result
|
|
53
78
|
else
|
|
54
|
-
|
|
79
|
+
record, was_new = Storage::DatabaseStorage.save_skill(
|
|
80
|
+
name: name, description: description, body: body,
|
|
81
|
+
tags: tags, bypass_guards_for_methods: bypass_guards_for_methods,
|
|
82
|
+
edited_by: edited_by || 'ai', change_note: change_note
|
|
83
|
+
)
|
|
84
|
+
status_note = if record.respond_to?(:proposed?) && record.proposed?
|
|
85
|
+
' — status: PROPOSED. A human must approve it at /rails_console_ai/skills before it can be activated.'
|
|
86
|
+
else
|
|
87
|
+
''
|
|
88
|
+
end
|
|
89
|
+
if was_new
|
|
90
|
+
"Skill created (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
|
|
91
|
+
else
|
|
92
|
+
"Skill updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
|
|
93
|
+
end
|
|
55
94
|
end
|
|
56
|
-
rescue Storage::StorageError => e
|
|
95
|
+
rescue Storage::StorageError, ::ActiveRecord::RecordInvalid => e
|
|
57
96
|
"FAILED to save skill (#{e.message})."
|
|
58
97
|
end
|
|
59
98
|
|
|
99
|
+
# Tries DB first, falls back to file. Reports which source it removed from.
|
|
60
100
|
def delete_skill(name:)
|
|
101
|
+
if Storage::DatabaseStorage.delete_skill_by_name(name)
|
|
102
|
+
return "Skill deleted (db): \"#{name}\""
|
|
103
|
+
end
|
|
104
|
+
|
|
61
105
|
key = skill_key(name)
|
|
62
106
|
unless @storage.exists?(key)
|
|
63
|
-
found =
|
|
107
|
+
found = safe_load_file_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
|
|
64
108
|
return "No skill found: \"#{name}\"" unless found
|
|
65
109
|
key = skill_key(found['name'])
|
|
66
110
|
end
|
|
67
111
|
|
|
68
|
-
skill =
|
|
112
|
+
skill = load_skill_file(key)
|
|
69
113
|
@storage.delete(key)
|
|
70
114
|
"Skill deleted: \"#{skill ? skill['name'] : name}\""
|
|
71
115
|
rescue Storage::StorageError => e
|
|
@@ -74,6 +118,40 @@ module RailsConsoleAi
|
|
|
74
118
|
|
|
75
119
|
private
|
|
76
120
|
|
|
121
|
+
def save_skill_to_file(name:, description:, body:, tags:, bypass_guards_for_methods:)
|
|
122
|
+
key = skill_key(name)
|
|
123
|
+
existing = load_skill_file(key)
|
|
124
|
+
|
|
125
|
+
content = self.class.dump(
|
|
126
|
+
name: name, description: description, body: body,
|
|
127
|
+
tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
|
|
128
|
+
)
|
|
129
|
+
@storage.write(key, content)
|
|
130
|
+
|
|
131
|
+
path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
|
|
132
|
+
if existing
|
|
133
|
+
"Skill updated: \"#{name}\" (#{path})"
|
|
134
|
+
else
|
|
135
|
+
"Skill created: \"#{name}\" (#{path})"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def safe_load_db_skills
|
|
140
|
+
Storage::DatabaseStorage.all_skills
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def safe_load_file_skills
|
|
144
|
+
keys = @storage.list("#{SKILLS_DIR}/*.md")
|
|
145
|
+
keys.filter_map { |key|
|
|
146
|
+
skill = load_skill_file(key)
|
|
147
|
+
next nil unless skill
|
|
148
|
+
skill.merge('source' => :file, 'file_key' => key)
|
|
149
|
+
}
|
|
150
|
+
rescue => e
|
|
151
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load file skills: #{e.message}")
|
|
152
|
+
[]
|
|
153
|
+
end
|
|
154
|
+
|
|
77
155
|
def skill_key(name)
|
|
78
156
|
slug = name.downcase.strip
|
|
79
157
|
.gsub(/[^a-z0-9\s-]/, '')
|
|
@@ -83,7 +161,7 @@ module RailsConsoleAi
|
|
|
83
161
|
"#{SKILLS_DIR}/#{slug}.md"
|
|
84
162
|
end
|
|
85
163
|
|
|
86
|
-
def
|
|
164
|
+
def load_skill_file(key)
|
|
87
165
|
content = @storage.read(key)
|
|
88
166
|
return nil if content.nil? || content.strip.empty?
|
|
89
167
|
parse_skill(content)
|
|
@@ -93,10 +171,33 @@ module RailsConsoleAi
|
|
|
93
171
|
end
|
|
94
172
|
|
|
95
173
|
def parse_skill(content)
|
|
174
|
+
self.class.parse(content)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Public: parse a raw .md (YAML frontmatter + body) string into a hash.
|
|
178
|
+
# Returns nil for content that doesn't have valid frontmatter so the caller
|
|
179
|
+
# can show a clear error instead of producing a half-formed record.
|
|
180
|
+
def self.parse(content)
|
|
96
181
|
return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
97
182
|
frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
|
|
98
183
|
body = $2.strip
|
|
99
184
|
frontmatter.merge('body' => body)
|
|
185
|
+
rescue Psych::SyntaxError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Inverse of parse: emit a canonical .md (frontmatter + body) string from
|
|
190
|
+
# structured attrs. Used by AI tool callers and file-fallback writers so
|
|
191
|
+
# the on-disk and DB representations stay byte-identical.
|
|
192
|
+
def self.dump(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
|
|
193
|
+
frontmatter = {
|
|
194
|
+
'name' => name,
|
|
195
|
+
'description' => description,
|
|
196
|
+
'tags' => Array(tags)
|
|
197
|
+
}
|
|
198
|
+
bypasses = Array(bypass_guards_for_methods)
|
|
199
|
+
frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
|
|
200
|
+
"---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
|
|
100
201
|
end
|
|
101
202
|
end
|
|
102
203
|
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
module Storage
|
|
3
|
+
# Thin facade over the AR-backed Skill / Memory tables.
|
|
4
|
+
#
|
|
5
|
+
# Not a drop-in Storage::Base adapter: the loaders read & write structured
|
|
6
|
+
# records, not opaque Markdown blobs, so we expose a small typed API instead.
|
|
7
|
+
# All methods are safe to call before `ai_db_setup` has run — they detect
|
|
8
|
+
# missing tables and return empty results / nil rather than raising.
|
|
9
|
+
module DatabaseStorage
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def available?
|
|
13
|
+
table_exists?('rails_console_ai_skills')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def memories_available?
|
|
17
|
+
table_exists?('rails_console_ai_memories')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def agents_available?
|
|
21
|
+
table_exists?('rails_console_ai_agents')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Ask the connection directly so we don't depend on the AR model being
|
|
25
|
+
# autoloaded yet. In a Rails console, the models in app/models are
|
|
26
|
+
# autoloaded lazily — the constant is `defined?`-false until first
|
|
27
|
+
# reference. Going through the connection avoids that timing trap.
|
|
28
|
+
def table_exists?(table_name)
|
|
29
|
+
return false unless defined?(::ActiveRecord)
|
|
30
|
+
conn = active_record_connection
|
|
31
|
+
return false unless conn
|
|
32
|
+
conn.table_exists?(table_name)
|
|
33
|
+
rescue ::ActiveRecord::ActiveRecordError, ::ActiveRecord::NoDatabaseError, NoMethodError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def active_record_connection
|
|
38
|
+
klass = RailsConsoleAi.configuration.connection_class
|
|
39
|
+
if klass
|
|
40
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
41
|
+
klass.connection
|
|
42
|
+
else
|
|
43
|
+
::ActiveRecord::Base.connection
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- Skills ---
|
|
48
|
+
|
|
49
|
+
def all_skills
|
|
50
|
+
return [] unless available?
|
|
51
|
+
RailsConsoleAi::Skill.alphabetical.map(&:to_hash)
|
|
52
|
+
rescue => e
|
|
53
|
+
warn_failure(:all_skills, e)
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_skill_by_name(name)
|
|
58
|
+
return nil unless available?
|
|
59
|
+
record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
60
|
+
record&.to_hash
|
|
61
|
+
rescue => e
|
|
62
|
+
warn_failure(:find_skill_by_name, e)
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [], edited_by: nil, change_note: nil)
|
|
67
|
+
ensure_tables!(:skills)
|
|
68
|
+
record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
69
|
+
record ||= RailsConsoleAi::Skill.new
|
|
70
|
+
was_new = record.new_record?
|
|
71
|
+
content = RailsConsoleAi::SkillLoader.dump(
|
|
72
|
+
name: name, description: description, body: body,
|
|
73
|
+
tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
|
|
74
|
+
)
|
|
75
|
+
record.update_with_version!(
|
|
76
|
+
{ content: content },
|
|
77
|
+
edited_by: edited_by,
|
|
78
|
+
change_note: change_note
|
|
79
|
+
)
|
|
80
|
+
[record, was_new]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def delete_skill_by_name(name)
|
|
84
|
+
return false unless available?
|
|
85
|
+
record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
86
|
+
return false unless record
|
|
87
|
+
record.destroy
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- Memories ---
|
|
92
|
+
|
|
93
|
+
def all_memories
|
|
94
|
+
return [] unless memories_available?
|
|
95
|
+
RailsConsoleAi::Memory.alphabetical.map(&:to_hash)
|
|
96
|
+
rescue => e
|
|
97
|
+
warn_failure(:all_memories, e)
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def find_memory_by_name(name)
|
|
102
|
+
return nil unless memories_available?
|
|
103
|
+
record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
104
|
+
record&.to_hash
|
|
105
|
+
rescue => e
|
|
106
|
+
warn_failure(:find_memory_by_name, e)
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def save_memory(name:, description:, tags: [], edited_by: nil, change_note: nil)
|
|
111
|
+
ensure_tables!(:memories)
|
|
112
|
+
record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
113
|
+
record ||= RailsConsoleAi::Memory.new
|
|
114
|
+
was_new = record.new_record?
|
|
115
|
+
content = RailsConsoleAi::Tools::MemoryTools.dump(
|
|
116
|
+
name: name, description: description, tags: tags
|
|
117
|
+
)
|
|
118
|
+
record.update_with_version!(
|
|
119
|
+
{ content: content },
|
|
120
|
+
edited_by: edited_by,
|
|
121
|
+
change_note: change_note
|
|
122
|
+
)
|
|
123
|
+
[record, was_new]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def delete_memory_by_name(name)
|
|
127
|
+
return false unless memories_available?
|
|
128
|
+
record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
129
|
+
return false unless record
|
|
130
|
+
record.destroy
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- Agents ---
|
|
135
|
+
|
|
136
|
+
def all_agents
|
|
137
|
+
return [] unless agents_available?
|
|
138
|
+
RailsConsoleAi::Agent.alphabetical.map(&:to_hash)
|
|
139
|
+
rescue => e
|
|
140
|
+
warn_failure(:all_agents, e)
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def find_agent_by_name(name)
|
|
145
|
+
return nil unless agents_available?
|
|
146
|
+
record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
147
|
+
record&.to_hash
|
|
148
|
+
rescue => e
|
|
149
|
+
warn_failure(:find_agent_by_name, e)
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def save_agent(name:, description:, body:, max_rounds: nil, model: nil, tools: [], edited_by: nil, change_note: nil)
|
|
154
|
+
ensure_tables!(:agents)
|
|
155
|
+
record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
156
|
+
record ||= RailsConsoleAi::Agent.new
|
|
157
|
+
was_new = record.new_record?
|
|
158
|
+
content = RailsConsoleAi::AgentLoader.dump(
|
|
159
|
+
name: name, description: description, body: body,
|
|
160
|
+
max_rounds: max_rounds, model: model, tools: tools
|
|
161
|
+
)
|
|
162
|
+
record.update_with_version!(
|
|
163
|
+
{ content: content },
|
|
164
|
+
edited_by: edited_by,
|
|
165
|
+
change_note: change_note
|
|
166
|
+
)
|
|
167
|
+
[record, was_new]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def delete_agent_by_name(name)
|
|
171
|
+
return false unless agents_available?
|
|
172
|
+
record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
|
|
173
|
+
return false unless record
|
|
174
|
+
record.destroy
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ensure_tables!(kind)
|
|
179
|
+
ready = case kind
|
|
180
|
+
when :skills then available?
|
|
181
|
+
when :memories then memories_available?
|
|
182
|
+
when :agents then agents_available?
|
|
183
|
+
else false
|
|
184
|
+
end
|
|
185
|
+
return if ready
|
|
186
|
+
raise StorageError, "rails_console_ai_#{kind} table does not exist. Run `ai_db_setup` in your console."
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def warn_failure(method, error)
|
|
190
|
+
return unless defined?(RailsConsoleAi.logger) && RailsConsoleAi.logger
|
|
191
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi::Storage::DatabaseStorage##{method} failed: #{error.class}: #{error.message}")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
|
+
require 'rails_console_ai/storage/database_storage'
|
|
2
3
|
|
|
3
4
|
module RailsConsoleAi
|
|
4
5
|
module Tools
|
|
@@ -9,41 +10,61 @@ module RailsConsoleAi
|
|
|
9
10
|
@storage = storage || RailsConsoleAi.storage
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
frontmatter['updated_at'] = Time.now.utc.iso8601 if existing
|
|
22
|
-
|
|
23
|
-
content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
|
|
24
|
-
@storage.write(key, content)
|
|
13
|
+
# target: :db (default) | :file
|
|
14
|
+
# Falls back to :file (with a notice in the return string) if DB tables aren't set up.
|
|
15
|
+
def save_memory(name:, description:, tags: [], target: :db, edited_by: nil, change_note: nil)
|
|
16
|
+
target = (target || :db).to_sym
|
|
17
|
+
db_fell_back = false
|
|
18
|
+
if target == :db && !Storage::DatabaseStorage.memories_available?
|
|
19
|
+
target = :file
|
|
20
|
+
db_fell_back = true
|
|
21
|
+
end
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
if target == :file
|
|
24
|
+
result = save_memory_to_file(name: name, description: description, tags: tags)
|
|
25
|
+
if db_fell_back
|
|
26
|
+
result += "\nNOTE: DB storage was requested but the rails_console_ai_memories table does not exist. " \
|
|
27
|
+
"Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
|
|
28
|
+
"Saved to a file instead."
|
|
29
|
+
end
|
|
30
|
+
result
|
|
29
31
|
else
|
|
30
|
-
|
|
32
|
+
record, was_new = Storage::DatabaseStorage.save_memory(
|
|
33
|
+
name: name, description: description, tags: tags,
|
|
34
|
+
edited_by: edited_by || 'ai', change_note: change_note
|
|
35
|
+
)
|
|
36
|
+
if was_new
|
|
37
|
+
"Memory saved (db): \"#{record.name}\" (id=#{record.id})"
|
|
38
|
+
else
|
|
39
|
+
"Memory updated (db): \"#{record.name}\" (id=#{record.id})"
|
|
40
|
+
end
|
|
31
41
|
end
|
|
32
42
|
rescue Storage::StorageError => e
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
if target == :file
|
|
44
|
+
# Preserve the original behaviour: include a hint with the raw frontmatter
|
|
45
|
+
# so the user (or AI) can paste it manually when the filesystem is read-only.
|
|
46
|
+
"FAILED to save (#{e.message}). Add this manually to .rails_console_ai/#{memory_key(name)}:\n" \
|
|
47
|
+
"---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
|
|
48
|
+
else
|
|
49
|
+
"FAILED to save (#{e.message})."
|
|
50
|
+
end
|
|
51
|
+
rescue ::ActiveRecord::RecordInvalid => e
|
|
52
|
+
"FAILED to save (#{e.message})."
|
|
35
53
|
end
|
|
36
54
|
|
|
37
55
|
def delete_memory(name:)
|
|
56
|
+
if Storage::DatabaseStorage.delete_memory_by_name(name)
|
|
57
|
+
return "Memory deleted (db): \"#{name}\""
|
|
58
|
+
end
|
|
59
|
+
|
|
38
60
|
key = memory_key(name)
|
|
39
61
|
unless @storage.exists?(key)
|
|
40
|
-
# Try to find by name match across all memory files
|
|
41
62
|
found_key = find_memory_key_by_name(name)
|
|
42
63
|
return "No memory found: \"#{name}\"" unless found_key
|
|
43
64
|
key = found_key
|
|
44
65
|
end
|
|
45
66
|
|
|
46
|
-
memory =
|
|
67
|
+
memory = load_memory_file(key)
|
|
47
68
|
@storage.delete(key)
|
|
48
69
|
"Memory deleted: \"#{memory ? memory['name'] : name}\""
|
|
49
70
|
rescue Storage::StorageError => e
|
|
@@ -51,14 +72,11 @@ module RailsConsoleAi
|
|
|
51
72
|
end
|
|
52
73
|
|
|
53
74
|
def recall_memory(name:)
|
|
54
|
-
|
|
55
|
-
memory = load_memory(key)
|
|
56
|
-
# Fall back to case-insensitive name search
|
|
57
|
-
unless memory
|
|
58
|
-
memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.downcase }
|
|
59
|
-
end
|
|
75
|
+
memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
|
|
60
76
|
return "No memory found: \"#{name}\"" unless memory
|
|
61
77
|
|
|
78
|
+
record_use(memory)
|
|
79
|
+
|
|
62
80
|
line = "**#{memory['name']}**\n#{memory['description']}"
|
|
63
81
|
line += "\nTags: #{memory['tags'].join(', ')}" if memory['tags'] && !memory['tags'].empty?
|
|
64
82
|
line
|
|
@@ -88,6 +106,9 @@ module RailsConsoleAi
|
|
|
88
106
|
|
|
89
107
|
return "No memories matching your search." if results.empty?
|
|
90
108
|
|
|
109
|
+
# Every memory in the result set was loaded into the AI's context.
|
|
110
|
+
results.each { |m| record_use(m) }
|
|
111
|
+
|
|
91
112
|
results.map { |m|
|
|
92
113
|
line = "**#{m['name']}**\n#{m['description']}"
|
|
93
114
|
line += "\nTags: #{m['tags'].join(', ')}" if m['tags'] && !m['tags'].empty?
|
|
@@ -106,8 +127,44 @@ module RailsConsoleAi
|
|
|
106
127
|
}
|
|
107
128
|
end
|
|
108
129
|
|
|
130
|
+
def load_all_memories
|
|
131
|
+
db = Storage::DatabaseStorage.all_memories
|
|
132
|
+
file = load_all_file_memories
|
|
133
|
+
names = db.map { |m| m['name'].to_s.downcase }
|
|
134
|
+
file.reject! { |m| names.include?(m['name'].to_s.downcase) }
|
|
135
|
+
(db + file).sort_by { |m| m['name'].to_s.downcase }
|
|
136
|
+
end
|
|
137
|
+
|
|
109
138
|
private
|
|
110
139
|
|
|
140
|
+
# DB-backed memories only — file memories have no row to update.
|
|
141
|
+
def record_use(memory)
|
|
142
|
+
return unless memory.is_a?(Hash) && memory['source'] == :db && memory['id']
|
|
143
|
+
RailsConsoleAi::Memory.record_use!(memory['id'])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def save_memory_to_file(name:, description:, tags:)
|
|
147
|
+
key = memory_key(name)
|
|
148
|
+
existing = load_memory_file(key)
|
|
149
|
+
|
|
150
|
+
frontmatter = {
|
|
151
|
+
'name' => name,
|
|
152
|
+
'tags' => Array(tags).empty? && existing ? (existing['tags'] || []) : Array(tags),
|
|
153
|
+
'created_at' => existing ? existing['created_at'] : Time.now.utc.iso8601
|
|
154
|
+
}
|
|
155
|
+
frontmatter['updated_at'] = Time.now.utc.iso8601 if existing
|
|
156
|
+
|
|
157
|
+
content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
|
|
158
|
+
@storage.write(key, content)
|
|
159
|
+
|
|
160
|
+
path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
|
|
161
|
+
if existing
|
|
162
|
+
"Memory updated: \"#{name}\" (#{path})"
|
|
163
|
+
else
|
|
164
|
+
"Memory saved: \"#{name}\" (#{path})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
111
168
|
def memory_key(name)
|
|
112
169
|
slug = name.downcase.strip
|
|
113
170
|
.gsub(/[^a-z0-9\s-]/, '')
|
|
@@ -117,7 +174,7 @@ module RailsConsoleAi
|
|
|
117
174
|
"#{MEMORIES_DIR}/#{slug}.md"
|
|
118
175
|
end
|
|
119
176
|
|
|
120
|
-
def
|
|
177
|
+
def load_memory_file(key)
|
|
121
178
|
content = @storage.read(key)
|
|
122
179
|
return nil if content.nil? || content.strip.empty?
|
|
123
180
|
parse_memory(content)
|
|
@@ -126,26 +183,47 @@ module RailsConsoleAi
|
|
|
126
183
|
nil
|
|
127
184
|
end
|
|
128
185
|
|
|
129
|
-
def
|
|
186
|
+
def load_all_file_memories
|
|
130
187
|
keys = @storage.list("#{MEMORIES_DIR}/*.md")
|
|
131
|
-
keys.
|
|
188
|
+
keys.filter_map { |key|
|
|
189
|
+
memory = load_memory_file(key)
|
|
190
|
+
next nil unless memory
|
|
191
|
+
memory.merge('source' => :file, 'file_key' => key)
|
|
192
|
+
}
|
|
132
193
|
rescue => e
|
|
133
194
|
RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load memories: #{e.message}")
|
|
134
195
|
[]
|
|
135
196
|
end
|
|
136
197
|
|
|
137
198
|
def parse_memory(content)
|
|
199
|
+
self.class.parse(content)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Public: parse a raw .md (YAML frontmatter + body) string into a hash.
|
|
203
|
+
# For memories, the body is stored under the 'description' key (memories
|
|
204
|
+
# don't have a separate description vs body — the markdown IS the memory).
|
|
205
|
+
def self.parse(content)
|
|
138
206
|
return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
139
207
|
frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
|
|
140
208
|
description = $2.strip
|
|
141
209
|
frontmatter.merge('description' => description)
|
|
210
|
+
rescue Psych::SyntaxError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Inverse of parse: emit a canonical .md string. The DB store uses this
|
|
215
|
+
# minimal form (name + tags only in frontmatter); the file store layers
|
|
216
|
+
# created_at/updated_at on top via save_memory_to_file.
|
|
217
|
+
def self.dump(name:, description:, tags: [])
|
|
218
|
+
frontmatter = { 'name' => name, 'tags' => Array(tags) }
|
|
219
|
+
"---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
|
|
142
220
|
end
|
|
143
221
|
|
|
144
222
|
def find_memory_key_by_name(name)
|
|
145
223
|
keys = @storage.list("#{MEMORIES_DIR}/*.md")
|
|
146
224
|
keys.find do |key|
|
|
147
|
-
memory =
|
|
148
|
-
memory && memory['name'].to_s.downcase == name.downcase
|
|
225
|
+
memory = load_memory_file(key)
|
|
226
|
+
memory && memory['name'].to_s.downcase == name.to_s.downcase
|
|
149
227
|
end
|
|
150
228
|
end
|
|
151
229
|
end
|