llmemory 0.1.17 → 0.2.1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +178 -1
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
  4. data/lib/llmemory/actions/reason.rb +49 -0
  5. data/lib/llmemory/actions.rb +8 -0
  6. data/lib/llmemory/cli/commands/base.rb +8 -0
  7. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  8. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  9. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  10. data/lib/llmemory/cli/commands/working.rb +31 -0
  11. data/lib/llmemory/cli.rb +12 -0
  12. data/lib/llmemory/configuration.rb +6 -0
  13. data/lib/llmemory/forget_log.rb +50 -0
  14. data/lib/llmemory/long_term/episodic/memory.rb +97 -15
  15. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  16. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  17. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +93 -0
  18. data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
  19. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
  20. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +11 -3
  21. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +9 -3
  22. data/lib/llmemory/long_term/file_based/memory.rb +31 -0
  23. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
  24. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
  25. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
  26. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
  27. data/lib/llmemory/long_term/graph_based/memory.rb +95 -51
  28. data/lib/llmemory/long_term/procedural/memory.rb +170 -0
  29. data/lib/llmemory/long_term/procedural/skill.rb +93 -0
  30. data/lib/llmemory/long_term/procedural/storage.rb +33 -0
  31. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  32. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
  33. data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
  34. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
  35. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +135 -0
  36. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +79 -0
  37. data/lib/llmemory/long_term/procedural.rb +12 -0
  38. data/lib/llmemory/long_term.rb +2 -0
  39. data/lib/llmemory/mcp/server.rb +13 -1
  40. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  41. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  42. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  43. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  44. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  45. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  46. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  47. data/lib/llmemory/memory.rb +34 -1
  48. data/lib/llmemory/memory_module.rb +55 -0
  49. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  50. data/lib/llmemory/retrieval/engine.rb +115 -6
  51. data/lib/llmemory/retrieval/feedback_store.rb +50 -0
  52. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  53. data/lib/llmemory/short_term/checkpoint.rb +2 -14
  54. data/lib/llmemory/short_term/session_lifecycle.rb +22 -13
  55. data/lib/llmemory/short_term/stores.rb +27 -0
  56. data/lib/llmemory/tokenizer.rb +27 -0
  57. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  58. data/lib/llmemory/vector_store.rb +14 -0
  59. data/lib/llmemory/version.rb +1 -1
  60. data/lib/llmemory/working_memory.rb +83 -0
  61. data/lib/llmemory.rb +5 -0
  62. metadata +32 -1
@@ -6,11 +6,14 @@ require_relative "knowledge_graph"
6
6
  require_relative "conflict_resolver"
7
7
  require_relative "storage"
8
8
  require_relative "../../noise_filter"
9
+ require_relative "../../memory_module"
9
10
 
10
11
  module Llmemory
11
12
  module LongTerm
12
13
  module GraphBased
13
14
  class Memory
15
+ include Llmemory::MemoryModule
16
+
14
17
  def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil)
15
18
  @user_id = user_id
16
19
  @graph_storage = storage || Storages.build
@@ -25,14 +28,95 @@ module Llmemory
25
28
  text = Llmemory.configuration.noise_filter_enabled ? NoiseFilter.filter?(conversation_text) : conversation_text.to_s
26
29
  return true if text.strip.empty?
27
30
 
28
- data = @extractor.extract(text) rescue { entities: [], relations: [] }
29
- data = { entities: [], relations: [] } unless data.is_a?(Hash)
30
- entities = Array(data[:entities] || data["entities"])
31
- relations = Array(data[:relations] || data["relations"])
32
-
31
+ entities, relations = extract_graph(text)
33
32
  return true if entities.empty? && relations.empty?
34
33
 
35
34
  provenance = Llmemory::Provenance.from_text_fingerprint(text, method: "entity_relation_extraction")
35
+ ingest(entities, relations, provenance)
36
+ true
37
+ end
38
+
39
+ def retrieve(query, top_k: 10)
40
+ results = hybrid_search(query, top_k: top_k)
41
+ format_as_context(results)
42
+ end
43
+
44
+ def search_candidates(query, user_id: nil, top_k: 20)
45
+ uid = user_id || @user_id
46
+ return [] unless uid == @user_id
47
+ results = hybrid_search(query, top_k: top_k)
48
+ results.map do |r|
49
+ {
50
+ id: r[:id],
51
+ text: r[:text],
52
+ timestamp: r[:created_at] || r[:timestamp],
53
+ score: r[:score] || 1.0,
54
+ importance: r[:importance]
55
+ }
56
+ end
57
+ end
58
+
59
+ attr_reader :user_id
60
+
61
+ def storage
62
+ @graph_storage
63
+ end
64
+
65
+ # Stores a fact produced outside the conversational flow (e.g. a
66
+ # reflection insight) by extracting entities/relations from `content` and
67
+ # adding them to the graph, preserving caller-supplied provenance. Lets
68
+ # the Reflector target graph-based semantic memory.
69
+ def remember_fact(content:, category: nil, importance: nil, provenance: nil)
70
+ return nil if content.to_s.strip.empty?
71
+ entities, relations = extract_graph(content)
72
+ return nil if entities.empty? && relations.empty?
73
+ prov = provenance || Llmemory::Provenance.from_text_fingerprint(content, method: "reflection")
74
+ ingest(entities, relations, prov)
75
+ true
76
+ end
77
+
78
+ # --- MemoryModule uniform interface ---
79
+
80
+ def write(payload, **_meta)
81
+ memorize(payload)
82
+ end
83
+
84
+ def list(user_id: nil, limit: nil)
85
+ @graph_storage.list_nodes(user_id || @user_id, limit: limit)
86
+ end
87
+
88
+ def stats(user_id: nil)
89
+ uid = user_id || @user_id
90
+ { nodes: @graph_storage.count_nodes(uid), edges: @graph_storage.count_edges(uid) }
91
+ end
92
+
93
+ # Forgets relations by archiving the edges identified by the candidate
94
+ # ids returned from #read/#search_candidates (edge ids), recording the
95
+ # removal in the audit log. Edges are soft-archived (archived_at) so they
96
+ # no longer appear in retrieval; nodes are left in place (a node may still
97
+ # be referenced by other active edges). Returns the number archived.
98
+ def forget(ids:, reason: nil)
99
+ archived = Array(ids).map(&:to_s).select { |edge_id| @kg.archive_edge(edge_id) }
100
+ forget_log.record(@user_id, memory_type: "graph_based", ids: archived, reason: reason)
101
+ archived.size
102
+ end
103
+
104
+ private
105
+
106
+ def build_vector_store
107
+ Llmemory::VectorStore.build(source_type: "edge")
108
+ end
109
+
110
+ def extract_graph(text)
111
+ data = @extractor.extract(text) rescue { entities: [], relations: [] }
112
+ data = { entities: [], relations: [] } unless data.is_a?(Hash)
113
+ [Array(data[:entities] || data["entities"]), Array(data[:relations] || data["relations"])]
114
+ end
115
+
116
+ # Adds entities and relations to the graph (nodes, edges, embeddings) with
117
+ # the given provenance. Shared by memorize (conversation) and
118
+ # remember_fact (reflection).
119
+ def ingest(entities, relations, provenance)
36
120
  name_to_id = {}
37
121
 
38
122
  entities.each do |e|
@@ -67,52 +151,12 @@ module Llmemory
67
151
  @conflict_resolver.resolve(edge)
68
152
  edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: { "provenance" => provenance })
69
153
 
70
- text = "#{subject} #{predicate} #{object}"
71
- embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(text) : nil
154
+ edge_text = "#{subject} #{predicate} #{object}"
155
+ embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(edge_text) : nil
72
156
  if embedding && @vector_store.respond_to?(:store)
73
- @vector_store.store(id: "edge_#{edge_id}", embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
157
+ @vector_store.store(id: edge_id, embedding: embedding, metadata: { text: edge_text, created_at: Time.now }, user_id: @user_id)
74
158
  end
75
159
  end
76
-
77
- true
78
- end
79
-
80
- def retrieve(query, top_k: 10)
81
- results = hybrid_search(query, top_k: top_k)
82
- format_as_context(results)
83
- end
84
-
85
- def search_candidates(query, user_id: nil, top_k: 20)
86
- uid = user_id || @user_id
87
- return [] unless uid == @user_id
88
- results = hybrid_search(query, top_k: top_k)
89
- results.map do |r|
90
- {
91
- text: r[:text],
92
- timestamp: r[:created_at] || r[:timestamp],
93
- score: r[:score] || 1.0,
94
- importance: r[:importance]
95
- }
96
- end
97
- end
98
-
99
- attr_reader :user_id
100
-
101
- def storage
102
- @graph_storage
103
- end
104
-
105
- private
106
-
107
- def build_vector_store
108
- emb = Llmemory::VectorStore::OpenAIEmbeddings.new
109
- store_type = (Llmemory.configuration.long_term_store || :memory).to_s.to_sym
110
- if store_type == :active_record || store_type == :activerecord
111
- require_relative "../../vector_store/active_record_store"
112
- Llmemory::VectorStore::ActiveRecordStore.new(embedding_provider: emb)
113
- else
114
- Llmemory::VectorStore::MemoryStore.new(embedding_provider: emb)
115
- end
116
160
  end
117
161
 
118
162
  def hybrid_search(query, top_k:)
@@ -127,7 +171,7 @@ module Llmemory
127
171
  out = vector_results.map do |v|
128
172
  id = v[:id] || v["id"]
129
173
  meta = v[:metadata] || v["metadata"] || {}
130
- { text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
174
+ { id: id, text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
131
175
  end
132
176
 
133
177
  node_ids = out.flat_map { |r| extract_node_ids_from_text(r[:text]) }.compact.uniq
@@ -139,7 +183,7 @@ module Llmemory
139
183
  subj = @kg.find_node_by_id(e.subject_id)
140
184
  obj = @kg.find_node_by_id(e.target_id)
141
185
  edge_text = "#{subj&.name} #{e.predicate} #{obj&.name}"
142
- out << { text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
186
+ out << { id: e.id, text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
143
187
  end
144
188
  end
145
189
 
@@ -152,7 +196,7 @@ module Llmemory
152
196
  obj = @kg.find_node_by_id(e.target_id)
153
197
  next unless subj && obj
154
198
  edge_text = "#{subj.name} #{e.predicate} #{obj.name}"
155
- out << { text: edge_text, score: 0.7, created_at: e.created_at }
199
+ out << { id: e.id, text: edge_text, score: 0.7, created_at: e.created_at }
156
200
  end
157
201
  end
158
202
 
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skill"
4
+ require_relative "storage"
5
+ require_relative "../../memory_module"
6
+ require_relative "../../vector_store"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ # Procedural long-term memory: a Voyager-style skill library. Agents
12
+ # register reusable skills (prompts, templates, code), retrieve them by
13
+ # relevance to the current task, and report outcomes so proven skills are
14
+ # preferred over unproven ones.
15
+ #
16
+ # The success rate of each skill is surfaced as `importance`, so the
17
+ # retrieval Engine ranks battle-tested skills higher (P3). Semantic
18
+ # (embedding) retrieval is opt-in via `config.procedural_vector_enabled` or
19
+ # by injecting a `vector_store:`; when off, search is keyword-only.
20
+ class Memory
21
+ include Llmemory::MemoryModule
22
+
23
+ attr_reader :user_id, :storage
24
+
25
+ def initialize(user_id:, storage: nil, vector_store: nil)
26
+ @user_id = user_id
27
+ @storage = storage || Storages.build
28
+ @vector_store = vector_store
29
+ @vector_explicit = !vector_store.nil?
30
+ end
31
+
32
+ # Registers a skill. If `version` is omitted and a skill with the same
33
+ # name exists, the version auto-increments (skill evolution).
34
+ def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil)
35
+ version ||= next_version_for(name)
36
+ skill = Skill.new(
37
+ id: nil, user_id: @user_id, name: name, body: body,
38
+ description: description, kind: kind, version: version
39
+ )
40
+ id = @storage.save_skill(@user_id, skill.to_h)
41
+ index_vector(id, skill.searchable_text)
42
+ id
43
+ end
44
+
45
+ def find_skill(query)
46
+ raw = @storage.search_skills(@user_id, query).first
47
+ raw && Skill.from_h(raw)
48
+ end
49
+
50
+ def get_skill(id)
51
+ raw = @storage.get_skill(@user_id, id)
52
+ raw && Skill.from_h(raw)
53
+ end
54
+
55
+ def skills(limit: nil)
56
+ @storage.list_skills(@user_id, limit: limit).map { |s| Skill.from_h(s) }
57
+ end
58
+
59
+ def count
60
+ @storage.count_skills(@user_id)
61
+ end
62
+
63
+ # Records that applying a skill succeeded or failed. Feeds retrieval
64
+ # ranking and adaptive retrieval (P8). Returns the updated Skill.
65
+ def report_outcome(skill_id, success:)
66
+ raw = @storage.record_outcome(@user_id, skill_id, success: success)
67
+ raw && Skill.from_h(raw)
68
+ end
69
+
70
+ # Retrieval Engine integration: skills ranked by relevance, recency and
71
+ # proven utility (success rate exposed as importance). Hybrid (vector +
72
+ # keyword) when a vector store is active; otherwise keyword-only.
73
+ def search_candidates(query, user_id: nil, top_k: 20)
74
+ uid = user_id || @user_id
75
+ return [] unless uid == @user_id
76
+
77
+ keyword = @storage.search_skills(uid, query).first(top_k).map { |raw| candidate_for(raw, 1.0) }
78
+ vs = vector_store
79
+ return keyword unless vs
80
+
81
+ merge_candidates(vector_candidates(query, top_k, vs), keyword, top_k)
82
+ end
83
+
84
+ # --- MemoryModule uniform interface ---
85
+
86
+ def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, **_meta)
87
+ register_skill(name: name, body: body, description: description, kind: kind, version: version)
88
+ end
89
+
90
+ def list(user_id: nil, limit: nil)
91
+ skills(limit: limit)
92
+ end
93
+
94
+ def stats(user_id: nil)
95
+ { skills: count }
96
+ end
97
+
98
+ def forget(ids:, reason: nil)
99
+ requested = Array(ids).map(&:to_s)
100
+ existing = @storage.list_skills(@user_id).map { |s| (s[:id] || s["id"]).to_s }
101
+ removed = requested & existing
102
+ @storage.delete_skills(@user_id, removed)
103
+ forget_log.record(@user_id, memory_type: "procedural", ids: removed, reason: reason)
104
+ removed.size
105
+ end
106
+
107
+ private
108
+
109
+ def next_version_for(name)
110
+ existing = @storage.find_skills_by_name(@user_id, name)
111
+ return 1 if existing.empty?
112
+ existing.map { |s| (s[:version] || s["version"] || 1).to_i }.max + 1
113
+ end
114
+
115
+ # Active vector store: injected, or a config-gated lazy build; nil when
116
+ # semantic search is disabled (default).
117
+ def vector_store
118
+ if @vector_explicit
119
+ @vector_store
120
+ elsif Llmemory.configuration.procedural_vector_enabled
121
+ @vector_store ||= Llmemory::VectorStore.build(source_type: "skill")
122
+ end
123
+ end
124
+
125
+ # Best-effort embedding indexing; failures never break registration.
126
+ def index_vector(id, text)
127
+ vs = vector_store
128
+ return if vs.nil? || text.to_s.strip.empty?
129
+ embedding = vs.embed(text)
130
+ return unless embedding
131
+ vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def vector_candidates(query, top_k, vs)
137
+ vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
138
+ raw = @storage.get_skill(@user_id, r[:id] || r["id"])
139
+ raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
140
+ end
141
+ rescue StandardError
142
+ []
143
+ end
144
+
145
+ def candidate_for(raw, score)
146
+ skill = Skill.from_h(raw)
147
+ {
148
+ id: skill.id,
149
+ text: skill.searchable_text,
150
+ timestamp: skill.created_at,
151
+ score: score,
152
+ importance: skill.success_rate,
153
+ evergreen: false
154
+ }
155
+ end
156
+
157
+ # Dedup by id keeping the higher score; highest score first, capped.
158
+ def merge_candidates(primary, secondary, top_k)
159
+ by_id = {}
160
+ (primary + secondary).each do |c|
161
+ key = c[:id] || c[:text]
162
+ existing = by_id[key]
163
+ by_id[key] = c if existing.nil? || c[:score].to_f > existing[:score].to_f
164
+ end
165
+ by_id.values.sort_by { |c| -c[:score].to_f }.first(top_k)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Llmemory
6
+ module LongTerm
7
+ module Procedural
8
+ # A Skill is a reusable procedure an agent can retrieve and apply: a prompt,
9
+ # a template or a snippet of code. This is CoALA's "procedural memory" in the
10
+ # Voyager sense — a growing library of skills the agent learns and reuses.
11
+ #
12
+ # Skills track success/failure outcomes so proven skills can be preferred
13
+ # over unproven ones during retrieval (see #success_rate, and P8 adaptive
14
+ # retrieval).
15
+ class Skill
16
+ KINDS = %w[prompt template code].freeze
17
+ DEFAULT_KIND = "prompt"
18
+
19
+ attr_reader :id, :user_id, :name, :description, :body, :kind, :version,
20
+ :success_count, :failure_count, :created_at, :updated_at
21
+
22
+ def initialize(id:, user_id:, name:, body:, description: nil, kind: DEFAULT_KIND,
23
+ version: 1, success_count: 0, failure_count: 0, created_at: nil, updated_at: nil)
24
+ @id = id
25
+ @user_id = user_id
26
+ @name = name.to_s
27
+ @description = description
28
+ @body = body
29
+ @kind = normalize_kind(kind)
30
+ @version = version.to_i
31
+ @success_count = success_count.to_i
32
+ @failure_count = failure_count.to_i
33
+ @created_at = created_at || Time.now
34
+ @updated_at = updated_at || @created_at
35
+ end
36
+
37
+ # Proven utility in [0, 1]. Unproven skills (no outcomes) are neutral.
38
+ def success_rate
39
+ total = success_count + failure_count
40
+ total.zero? ? 0.5 : success_count.to_f / total
41
+ end
42
+
43
+ def searchable_text
44
+ [name, description, body].compact.map(&:to_s).reject(&:empty?).join("\n")
45
+ end
46
+
47
+ def normalize_kind(kind)
48
+ k = kind.to_s.strip.downcase
49
+ KINDS.include?(k) ? k : DEFAULT_KIND
50
+ end
51
+
52
+ def self.from_h(hash)
53
+ new(
54
+ id: hash[:id] || hash["id"],
55
+ user_id: hash[:user_id] || hash["user_id"],
56
+ name: hash[:name] || hash["name"],
57
+ description: hash[:description] || hash["description"],
58
+ body: hash[:body] || hash["body"],
59
+ kind: hash[:kind] || hash["kind"] || DEFAULT_KIND,
60
+ version: hash[:version] || hash["version"] || 1,
61
+ success_count: hash[:success_count] || hash["success_count"] || 0,
62
+ failure_count: hash[:failure_count] || hash["failure_count"] || 0,
63
+ created_at: parse_time(hash[:created_at] || hash["created_at"]),
64
+ updated_at: parse_time(hash[:updated_at] || hash["updated_at"])
65
+ )
66
+ end
67
+
68
+ def self.parse_time(value)
69
+ return value if value.nil? || value.is_a?(Time)
70
+ Time.parse(value.to_s)
71
+ rescue ArgumentError
72
+ nil
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ id: id,
78
+ user_id: user_id,
79
+ name: name,
80
+ description: description,
81
+ body: body,
82
+ kind: kind,
83
+ version: version,
84
+ success_count: success_count,
85
+ failure_count: failure_count,
86
+ created_at: created_at.respond_to?(:iso8601) ? created_at.iso8601(6) : created_at,
87
+ updated_at: updated_at.respond_to?(:iso8601) ? updated_at.iso8601(6) : updated_at
88
+ }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storages/base"
4
+ require_relative "storages/memory_storage"
5
+ require_relative "storages/file_storage"
6
+ require_relative "storages/database_storage"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ # Backward compatibility: Storage points to the in-memory backend.
12
+ Storage = Storages::MemoryStorage
13
+
14
+ module Storages
15
+ def self.build(store: nil, base_path: nil, database_url: nil)
16
+ case (store || Llmemory.configuration.long_term_store).to_s.to_sym
17
+ when :memory
18
+ MemoryStorage.new
19
+ when :file
20
+ FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
21
+ when :postgres, :database
22
+ DatabaseStorage.new(database_url: database_url || Llmemory.configuration.database_url)
23
+ when :active_record, :activerecord
24
+ require_relative "storages/active_record_storage"
25
+ ActiveRecordStorage.new
26
+ else
27
+ MemoryStorage.new
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Model for Procedural ActiveRecordStorage. Loaded only when using
4
+ # store: :active_record. JSONB `data` auto-deserializes to a Hash in Rails 5+.
5
+
6
+ module Llmemory
7
+ module LongTerm
8
+ module Procedural
9
+ module Storages
10
+ class LlmemorySkill < ::ActiveRecord::Base
11
+ self.table_name = "llmemory_skills"
12
+ self.primary_key = "id"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ module Storages
12
+ # ActiveRecord backend. Stores each skill as a JSONB `data` document; AR
13
+ # auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
14
+ # handles. Mirrors the file-based ActiveRecordStorage pattern.
15
+ class ActiveRecordStorage < Base
16
+ def initialize
17
+ self.class.load_models!
18
+ end
19
+
20
+ def self.load_models!
21
+ return if @models_loaded
22
+ require "active_record"
23
+ require_relative "active_record_models"
24
+ @models_loaded = true
25
+ end
26
+
27
+ def save_skill(user_id, skill)
28
+ id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
29
+ data = stringify(skill).merge("id" => id, "user_id" => user_id)
30
+ data["created_at"] ||= Time.now.utc.iso8601
31
+ rec = LlmemorySkill.find_or_initialize_by(id: id)
32
+ rec.user_id = user_id
33
+ rec.data = data
34
+ rec.search_text = searchable_text(data)
35
+ rec.created_at ||= Time.current
36
+ rec.save!
37
+ id
38
+ end
39
+
40
+ def get_skill(user_id, id)
41
+ LlmemorySkill.find_by(user_id: user_id, id: id)&.data
42
+ end
43
+
44
+ def list_skills(user_id, limit: nil)
45
+ scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
46
+ scope = scope.limit(limit) if limit && limit.to_i.positive?
47
+ scope.map(&:data)
48
+ end
49
+
50
+ def search_skills(user_id, query)
51
+ token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
52
+ .order(created_at: :desc).map(&:data)
53
+ end
54
+
55
+ def find_skills_by_name(user_id, name)
56
+ LlmemorySkill.where(user_id: user_id).where("data->>'name' = ?", name.to_s).map(&:data)
57
+ end
58
+
59
+ def record_outcome(user_id, skill_id, success:)
60
+ rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
61
+ return nil unless rec
62
+ data = rec.data || {}
63
+ key = success ? "success_count" : "failure_count"
64
+ data[key] = (data[key] || 0).to_i + 1
65
+ data["updated_at"] = Time.now.utc.iso8601
66
+ rec.data = data
67
+ rec.search_text = searchable_text(data)
68
+ rec.save!
69
+ data
70
+ end
71
+
72
+ def count_skills(user_id)
73
+ LlmemorySkill.where(user_id: user_id).count
74
+ end
75
+
76
+ def delete_skills(user_id, ids)
77
+ LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
78
+ end
79
+
80
+ def list_users
81
+ LlmemorySkill.distinct.pluck(:user_id)
82
+ end
83
+
84
+ private
85
+
86
+ def token_scope(scope, column, query)
87
+ tokens = Llmemory::Tokenizer.tokenize(query)
88
+ return scope if tokens.empty?
89
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
90
+ scope.where(clause, *tokens.map { |t| "%#{t}%" })
91
+ end
92
+
93
+ def stringify(hash)
94
+ JSON.parse(JSON.generate(hash))
95
+ end
96
+
97
+ def searchable_text(data)
98
+ [data["name"], data["description"], data["body"]].compact.join("\n")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LongTerm
5
+ module Procedural
6
+ module Storages
7
+ # Storage contract for procedural memory (skill library). Implementations
8
+ # persist Skill hashes, support keyword search and name lookup (for
9
+ # versioning), and record success/failure outcomes.
10
+ class Base
11
+ def save_skill(user_id, skill)
12
+ raise NotImplementedError, "#{self.class}#save_skill must be implemented"
13
+ end
14
+
15
+ def get_skill(user_id, id)
16
+ raise NotImplementedError, "#{self.class}#get_skill must be implemented"
17
+ end
18
+
19
+ def list_skills(user_id, limit: nil)
20
+ raise NotImplementedError, "#{self.class}#list_skills must be implemented"
21
+ end
22
+
23
+ def search_skills(user_id, query)
24
+ raise NotImplementedError, "#{self.class}#search_skills must be implemented"
25
+ end
26
+
27
+ def find_skills_by_name(user_id, name)
28
+ raise NotImplementedError, "#{self.class}#find_skills_by_name must be implemented"
29
+ end
30
+
31
+ # Increments the success or failure count of a skill and returns the
32
+ # updated skill hash (or nil if not found).
33
+ def record_outcome(user_id, skill_id, success:)
34
+ raise NotImplementedError, "#{self.class}#record_outcome must be implemented"
35
+ end
36
+
37
+ def count_skills(user_id)
38
+ raise NotImplementedError, "#{self.class}#count_skills must be implemented"
39
+ end
40
+
41
+ # Deletes skills by id. Returns the number actually removed.
42
+ def delete_skills(user_id, ids)
43
+ raise NotImplementedError, "#{self.class}#delete_skills must be implemented"
44
+ end
45
+
46
+ def list_users
47
+ raise NotImplementedError, "#{self.class}#list_users must be implemented"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end