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
@@ -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
- keys = @storage.list("#{SKILLS_DIR}/*.md")
13
- keys.filter_map { |key| load_skill(key) }
14
- rescue => e
15
- RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load skills: #{e.message}")
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 = load_all_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
- skills = load_all_skills
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
- def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
36
- key = skill_key(name)
37
- existing = find_skill(name)
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
- content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
48
- @storage.write(key, content)
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
- path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
51
- if existing
52
- "Skill updated: \"#{name}\" (#{path})"
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
- "Skill created: \"#{name}\" (#{path})"
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 = load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
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 = load_skill(key)
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 load_skill(key)
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
- def save_memory(name:, description:, tags: [])
13
- key = memory_key(name)
14
- existing = load_memory(key)
15
-
16
- frontmatter = {
17
- 'name' => name,
18
- 'tags' => Array(tags).empty? && existing ? (existing['tags'] || []) : Array(tags),
19
- 'created_at' => existing ? existing['created_at'] : Time.now.utc.iso8601
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
- path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
27
- if existing
28
- "Memory updated: \"#{name}\" (#{path})"
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
- "Memory saved: \"#{name}\" (#{path})"
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
- "FAILED to save (#{e.message}). Add this manually to .rails_console_ai/#{key}:\n" \
34
- "---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
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 = load_memory(key)
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
- key = memory_key(name)
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 load_memory(key)
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 load_all_memories
186
+ def load_all_file_memories
130
187
  keys = @storage.list("#{MEMORIES_DIR}/*.md")
131
- keys.map { |key| load_memory(key) }.compact
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 = load_memory(key)
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