rails_console_ai 0.29.0 → 0.30.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 +40 -0
  3. data/README.md +48 -0
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
  5. data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
  6. data/app/controllers/rails_console_ai/application_controller.rb +5 -0
  7. data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
  8. data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
  10. data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
  11. data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
  12. data/app/models/rails_console_ai/agent.rb +175 -0
  13. data/app/models/rails_console_ai/agent_version.rb +46 -0
  14. data/app/models/rails_console_ai/memory.rb +98 -0
  15. data/app/models/rails_console_ai/memory_version.rb +46 -0
  16. data/app/models/rails_console_ai/session.rb +1 -1
  17. data/app/models/rails_console_ai/skill.rb +198 -0
  18. data/app/models/rails_console_ai/skill_version.rb +54 -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 +65 -0
  23. data/app/views/rails_console_ai/agents/diff.html.erb +19 -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 +24 -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 +36 -0
  29. data/app/views/rails_console_ai/memories/diff.html.erb +19 -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 +23 -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 +65 -0
  39. data/app/views/rails_console_ai/skills/diff.html.erb +22 -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 +25 -0
  43. data/app/views/rails_console_ai/skills/show.html.erb +94 -0
  44. data/config/routes.rb +42 -0
  45. data/lib/rails_console_ai/agent_loader.rb +131 -43
  46. data/lib/rails_console_ai/agent_runner.rb +158 -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 +6 -0
  50. data/lib/rails_console_ai/skill_loader.rb +119 -27
  51. data/lib/rails_console_ai/storage/database_storage.rb +201 -0
  52. data/lib/rails_console_ai/tools/memory_tools.rb +102 -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 +256 -0
  56. data/lib/tasks/rails_console_ai.rake +7 -0
  57. metadata +55 -1
@@ -36,7 +36,7 @@ module RailsConsoleAi
36
36
 
37
37
  # --- Public API for channels ---
38
38
 
39
- def one_shot(query)
39
+ def one_shot(query, existing_session_id: nil)
40
40
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
41
  console_capture = StringIO.new
42
42
  exec_result = with_console_capture(console_capture) do
@@ -61,7 +61,8 @@ module RailsConsoleAi
61
61
  code_output: executed ? @executor.last_output : nil,
62
62
  code_result: executed && exec_result ? exec_result.inspect : nil,
63
63
  executed: executed,
64
- start_time: start_time
64
+ start_time: start_time,
65
+ existing_session_id: existing_session_id
65
66
  }
66
67
 
67
68
  exec_result
@@ -1016,18 +1017,23 @@ module RailsConsoleAi
1016
1017
 
1017
1018
  def log_session(attrs)
1018
1019
  require 'rails_console_ai/session_logger'
1019
- start_time = attrs.delete(:start_time)
1020
- duration_ms = if start_time
1021
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1022
- end
1023
- SessionLogger.log(
1024
- attrs.merge(
1025
- input_tokens: @total_input_tokens,
1026
- output_tokens: @total_output_tokens,
1027
- duration_ms: duration_ms,
1028
- model: effective_model
1029
- )
1020
+ start_time = attrs.delete(:start_time)
1021
+ existing_id = attrs.delete(:existing_session_id)
1022
+ duration_ms = if start_time
1023
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1024
+ end
1025
+ merged = attrs.merge(
1026
+ input_tokens: @total_input_tokens,
1027
+ output_tokens: @total_output_tokens,
1028
+ duration_ms: duration_ms,
1029
+ model: effective_model
1030
1030
  )
1031
+ if existing_id
1032
+ SessionLogger.update(existing_id, merged)
1033
+ existing_id
1034
+ else
1035
+ SessionLogger.log(merged)
1036
+ end
1031
1037
  end
1032
1038
 
1033
1039
  # --- Formatting helpers ---
@@ -25,6 +25,9 @@ module RailsConsoleAi
25
25
  }
26
26
  create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
27
  create_attrs[:slack_channel_name] = attrs[:slack_channel_name] if attrs[:slack_channel_name]
28
+ create_attrs[:status] = attrs[:status] if attrs.key?(:status)
29
+ create_attrs[:result] = attrs[:result] if attrs.key?(:result)
30
+ create_attrs[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
28
31
  record = session_class.create!(create_attrs)
29
32
  record.id
30
33
  rescue => e
@@ -59,6 +62,9 @@ module RailsConsoleAi
59
62
  updates[:executed] = attrs[:executed] if attrs.key?(:executed)
60
63
  updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
61
64
  updates[:name] = attrs[:name] if attrs.key?(:name)
65
+ updates[:status] = attrs[:status] if attrs.key?(:status)
66
+ updates[:result] = attrs[:result] if attrs.key?(:result)
67
+ updates[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
62
68
 
63
69
  session_class.where(id: id).update_all(updates) unless updates.empty?
64
70
  rescue => e
@@ -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,13 +46,81 @@ 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 }
50
+ end
51
+
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
56
+
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
66
+
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
78
+ else
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
94
+ end
95
+ rescue Storage::StorageError, ::ActiveRecord::RecordInvalid => e
96
+ "FAILED to save skill (#{e.message})."
33
97
  end
34
98
 
35
- def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
99
+ # Tries DB first, falls back to file. Reports which source it removed from.
100
+ def delete_skill(name:)
101
+ if Storage::DatabaseStorage.delete_skill_by_name(name)
102
+ return "Skill deleted (db): \"#{name}\""
103
+ end
104
+
36
105
  key = skill_key(name)
37
- existing = find_skill(name)
106
+ unless @storage.exists?(key)
107
+ found = safe_load_file_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
108
+ return "No skill found: \"#{name}\"" unless found
109
+ key = skill_key(found['name'])
110
+ end
111
+
112
+ skill = load_skill_file(key)
113
+ @storage.delete(key)
114
+ "Skill deleted: \"#{skill ? skill['name'] : name}\""
115
+ rescue Storage::StorageError => e
116
+ "FAILED to delete skill (#{e.message})."
117
+ end
118
+
119
+ private
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)
38
124
 
39
125
  frontmatter = {
40
126
  'name' => name,
@@ -53,26 +139,23 @@ module RailsConsoleAi
53
139
  else
54
140
  "Skill created: \"#{name}\" (#{path})"
55
141
  end
56
- rescue Storage::StorageError => e
57
- "FAILED to save skill (#{e.message})."
58
142
  end
59
143
 
60
- def delete_skill(name:)
61
- key = skill_key(name)
62
- unless @storage.exists?(key)
63
- found = load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
64
- return "No skill found: \"#{name}\"" unless found
65
- key = skill_key(found['name'])
66
- end
67
-
68
- skill = load_skill(key)
69
- @storage.delete(key)
70
- "Skill deleted: \"#{skill ? skill['name'] : name}\""
71
- rescue Storage::StorageError => e
72
- "FAILED to delete skill (#{e.message})."
144
+ def safe_load_db_skills
145
+ Storage::DatabaseStorage.all_skills
73
146
  end
74
147
 
75
- private
148
+ def safe_load_file_skills
149
+ keys = @storage.list("#{SKILLS_DIR}/*.md")
150
+ keys.filter_map { |key|
151
+ skill = load_skill_file(key)
152
+ next nil unless skill
153
+ skill.merge('source' => :file, 'file_key' => key)
154
+ }
155
+ rescue => e
156
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load file skills: #{e.message}")
157
+ []
158
+ end
76
159
 
77
160
  def skill_key(name)
78
161
  slug = name.downcase.strip
@@ -83,7 +166,7 @@ module RailsConsoleAi
83
166
  "#{SKILLS_DIR}/#{slug}.md"
84
167
  end
85
168
 
86
- def load_skill(key)
169
+ def load_skill_file(key)
87
170
  content = @storage.read(key)
88
171
  return nil if content.nil? || content.strip.empty?
89
172
  parse_skill(content)
@@ -93,10 +176,19 @@ module RailsConsoleAi
93
176
  end
94
177
 
95
178
  def parse_skill(content)
179
+ self.class.parse(content)
180
+ end
181
+
182
+ # Public: parse a raw .md (YAML frontmatter + body) string into a hash.
183
+ # Returns nil for content that doesn't have valid frontmatter so the caller
184
+ # can show a clear error instead of producing a half-formed record.
185
+ def self.parse(content)
96
186
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
97
187
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
98
188
  body = $2.strip
99
189
  frontmatter.merge('body' => body)
190
+ rescue Psych::SyntaxError
191
+ nil
100
192
  end
101
193
  end
102
194
  end
@@ -0,0 +1,201 @@
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
+ record.update_with_version!(
72
+ {
73
+ name: name,
74
+ description: description,
75
+ body: body,
76
+ tags: Array(tags),
77
+ bypass_guards_for_methods: Array(bypass_guards_for_methods)
78
+ },
79
+ edited_by: edited_by,
80
+ change_note: change_note
81
+ )
82
+ [record, was_new]
83
+ end
84
+
85
+ def delete_skill_by_name(name)
86
+ return false unless available?
87
+ record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
88
+ return false unless record
89
+ record.destroy
90
+ true
91
+ end
92
+
93
+ # --- Memories ---
94
+
95
+ def all_memories
96
+ return [] unless memories_available?
97
+ RailsConsoleAi::Memory.alphabetical.map(&:to_hash)
98
+ rescue => e
99
+ warn_failure(:all_memories, e)
100
+ []
101
+ end
102
+
103
+ def find_memory_by_name(name)
104
+ return nil unless memories_available?
105
+ record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
106
+ record&.to_hash
107
+ rescue => e
108
+ warn_failure(:find_memory_by_name, e)
109
+ nil
110
+ end
111
+
112
+ def save_memory(name:, description:, tags: [], edited_by: nil, change_note: nil)
113
+ ensure_tables!(:memories)
114
+ record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
115
+ record ||= RailsConsoleAi::Memory.new
116
+ was_new = record.new_record?
117
+ record.update_with_version!(
118
+ {
119
+ name: name,
120
+ description: description,
121
+ tags: Array(tags)
122
+ },
123
+ edited_by: edited_by,
124
+ change_note: change_note
125
+ )
126
+ [record, was_new]
127
+ end
128
+
129
+ def delete_memory_by_name(name)
130
+ return false unless memories_available?
131
+ record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
132
+ return false unless record
133
+ record.destroy
134
+ true
135
+ end
136
+
137
+ # --- Agents ---
138
+
139
+ def all_agents
140
+ return [] unless agents_available?
141
+ RailsConsoleAi::Agent.alphabetical.map(&:to_hash)
142
+ rescue => e
143
+ warn_failure(:all_agents, e)
144
+ []
145
+ end
146
+
147
+ def find_agent_by_name(name)
148
+ return nil unless agents_available?
149
+ record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
150
+ record&.to_hash
151
+ rescue => e
152
+ warn_failure(:find_agent_by_name, e)
153
+ nil
154
+ end
155
+
156
+ def save_agent(name:, description:, body:, max_rounds: nil, model: nil, tools: [], edited_by: nil, change_note: nil)
157
+ ensure_tables!(:agents)
158
+ record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
159
+ record ||= RailsConsoleAi::Agent.new
160
+ was_new = record.new_record?
161
+ record.update_with_version!(
162
+ {
163
+ name: name,
164
+ description: description,
165
+ body: body,
166
+ max_rounds: max_rounds,
167
+ model: model,
168
+ tools: Array(tools)
169
+ },
170
+ edited_by: edited_by,
171
+ change_note: change_note
172
+ )
173
+ [record, was_new]
174
+ end
175
+
176
+ def delete_agent_by_name(name)
177
+ return false unless agents_available?
178
+ record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
179
+ return false unless record
180
+ record.destroy
181
+ true
182
+ end
183
+
184
+ def ensure_tables!(kind)
185
+ ready = case kind
186
+ when :skills then available?
187
+ when :memories then memories_available?
188
+ when :agents then agents_available?
189
+ else false
190
+ end
191
+ return if ready
192
+ raise StorageError, "rails_console_ai_#{kind} table does not exist. Run `ai_db_setup` in your console."
193
+ end
194
+
195
+ def warn_failure(method, error)
196
+ return unless defined?(RailsConsoleAi.logger) && RailsConsoleAi.logger
197
+ RailsConsoleAi.logger.warn("RailsConsoleAi::Storage::DatabaseStorage##{method} failed: #{error.class}: #{error.message}")
198
+ end
199
+ end
200
+ end
201
+ 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,39 @@ 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
142
212
  end
143
213
 
144
214
  def find_memory_key_by_name(name)
145
215
  keys = @storage.list("#{MEMORIES_DIR}/*.md")
146
216
  keys.find do |key|
147
- memory = load_memory(key)
148
- memory && memory['name'].to_s.downcase == name.downcase
217
+ memory = load_memory_file(key)
218
+ memory && memory['name'].to_s.downcase == name.to_s.downcase
149
219
  end
150
220
  end
151
221
  end