Class: RailsConsoleAi::Tools::MemoryTools

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_console_ai/tools/memory_tools.rb

Constant Summary collapse

MEMORIES_DIR =
'memories'

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(storage = nil) ⇒ MemoryTools

Returns a new instance of MemoryTools.



9
10
11
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 9

def initialize(storage = nil)
  @storage = storage || RailsConsoleAi.storage
end

Class Method Details

.dump(name:, description:, tags: []) ⇒ Object

Inverse of parse: emit a canonical .md string. The DB store uses this minimal form (name + tags only in frontmatter); the file store layers created_at/updated_at on top via save_memory_to_file.



240
241
242
243
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 240

def self.dump(name:, description:, tags: [])
  frontmatter = { 'name' => name, 'tags' => Array(tags) }
  "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
end

.parse(content) ⇒ Object

Public: parse a raw .md (YAML frontmatter + body) string into a hash. For memories, the body is stored under the 'description' key (memories don't have a separate description vs body — the markdown IS the memory).



228
229
230
231
232
233
234
235
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 228

def self.parse(content)
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
  description = $2.strip
  frontmatter.merge('description' => description)
rescue Psych::SyntaxError
  nil
end

Instance Method Details

#delete_memory(name:) ⇒ Object



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 60

def delete_memory(name:)
  if Storage::DatabaseStorage.delete_memory_by_name(name)
    return "Memory deleted (db): \"#{name}\""
  end

  key = memory_key(name)
  unless @storage.exists?(key)
    found_key = find_memory_key_by_name(name)
    return "No memory found: \"#{name}\"" unless found_key
    key = found_key
  end

  memory = load_memory_file(key)
  @storage.delete(key)
  "Memory deleted: \"#{memory ? memory['name'] : name}\""
rescue Storage::StorageError => e
  "FAILED to delete memory (#{e.message})."
end

#load_activatable_memoriesObject

Memories the AI is allowed to see / recall: approved DB memories + all file memories. File memories are considered pre-approved because they're git-tracked.



157
158
159
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 157

def load_activatable_memories
  load_all_memories.reject { |m| m['source'] == :db && m['status'] != 'approved' }
end

#load_all_memoriesObject

Includes proposed (unapproved) DB memories — they show up in the admin UI with a "PROPOSED" badge. The AI-facing surface (#memory_summaries, #recall_memory, #recall_memories) filters them out via #load_activatable_memories, so an unapproved memory can never be recalled.



147
148
149
150
151
152
153
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 147

def load_all_memories
  db = Storage::DatabaseStorage.all_memories
  file = load_all_file_memories
  names = db.map { |m| m['name'].to_s.downcase }
  file.reject! { |m| names.include?(m['name'].to_s.downcase) }
  (db + file).sort_by { |m| m['name'].to_s.downcase }
end

#memory_summariesObject



132
133
134
135
136
137
138
139
140
141
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 132

def memory_summaries
  memories = load_activatable_memories
  return nil if memories.empty?

  memories.map { |m|
    tags = Array(m['tags'])
    tag_str = tags.empty? ? '' : " [#{tags.join(', ')}]"
    "- #{m['name']}#{tag_str}"
  }
end

#recall_memories(query: nil, tag: nil) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 98

def recall_memories(query: nil, tag: nil)
  memories = load_activatable_memories
  return "No memories stored yet." if memories.empty?

  results = memories
  if tag && !tag.empty?
    results = results.select { |m|
      Array(m['tags']).any? { |t| t.downcase.include?(tag.downcase) }
    }
  end
  if query && !query.empty?
    words = query.downcase.split(/\s+/)
    results = results.select { |m|
      searchable = [
        m['name'].to_s.downcase,
        m['description'].to_s.downcase,
        Array(m['tags']).map(&:downcase).join(' ')
      ].join(' ')
      words.all? { |w| searchable.include?(w) }
    }
  end

  return "No memories matching your search." if results.empty?

  # Every memory in the result set was loaded into the AI's context.
  results.each { |m| record_use(m) }

  results.map { |m|
    line = "**#{m['name']}**\n#{m['description']}"
    line += "\nTags: #{m['tags'].join(', ')}" if m['tags'] && !m['tags'].empty?
    line
  }.join("\n\n---\n\n")
end

#recall_memory(name:) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 79

def recall_memory(name:)
  memory = load_activatable_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
  unless memory
    # Distinguish "doesn't exist" from "exists but isn't approved yet".
    proposed = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
    if proposed && proposed['source'] == :db && proposed['status'] != 'approved'
      return "Memory \"#{name}\" exists but is awaiting human approval and cannot be recalled yet. " \
             "Ask the user to approve it in the web UI at /rails_console_ai/memories."
    end
    return "No memory found: \"#{name}\""
  end

  record_use(memory)

  line = "**#{memory['name']}**\n#{memory['description']}"
  line += "\nTags: #{memory['tags'].join(', ')}" if memory['tags'] && !memory['tags'].empty?
  line
end

#save_memory(name:, description:, tags: [], target: :db, edited_by: nil, change_note: nil) ⇒ Object

target: :db (default) | :file Falls back to :file (with a notice in the return string) if DB tables aren't set up.



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/rails_console_ai/tools/memory_tools.rb', line 15

def save_memory(name:, description:, tags: [], target: :db, edited_by: nil, change_note: nil)
  target = (target || :db).to_sym
  db_fell_back = false
  if target == :db && !Storage::DatabaseStorage.memories_available?
    target = :file
    db_fell_back = true
  end

  if target == :file
    result = save_memory_to_file(name: name, description: description, tags: tags)
    if db_fell_back
      result += "\nNOTE: DB storage was requested but the rails_console_ai_memories table does not exist. " \
                "Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
                "Saved to a file instead."
    end
    result
  else
    record, was_new = Storage::DatabaseStorage.save_memory(
      name: name, description: description, tags: tags,
      edited_by: edited_by || 'ai', change_note: change_note
    )
    status_note = if record.respond_to?(:proposed?) && record.proposed?
                    ' — status: PROPOSED. A human must approve it at /rails_console_ai/memories before you can recall it.'
                  else
                    ''
                  end
    if was_new
      "Memory saved (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
    else
      "Memory updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
    end
  end
rescue Storage::StorageError => e
  if target == :file
    # Preserve the original behaviour: include a hint with the raw frontmatter
    # so the user (or AI) can paste it manually when the filesystem is read-only.
    "FAILED to save (#{e.message}). Add this manually to .rails_console_ai/#{memory_key(name)}:\n" \
    "---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
  else
    "FAILED to save (#{e.message})."
  end
rescue ::ActiveRecord::RecordInvalid => e
  "FAILED to save (#{e.message})."
end