llmemory 0.2.2 → 0.2.4

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1
  3. data/lib/llmemory/cli/commands/stats.rb +5 -0
  4. data/lib/llmemory/configuration.rb +22 -2
  5. data/lib/llmemory/crypto/cipher.rb +147 -0
  6. data/lib/llmemory/crypto/field_helpers.rb +110 -0
  7. data/lib/llmemory/instrumentation.rb +4 -2
  8. data/lib/llmemory/llm/anthropic.rb +10 -4
  9. data/lib/llmemory/llm/base.rb +42 -0
  10. data/lib/llmemory/llm/openai.rb +29 -13
  11. data/lib/llmemory/llm/response.rb +18 -0
  12. data/lib/llmemory/llm/tracking_client.rb +61 -0
  13. data/lib/llmemory/llm/usage.rb +31 -0
  14. data/lib/llmemory/llm/usage_ledger.rb +118 -0
  15. data/lib/llmemory/llm/usage_recorder.rb +37 -0
  16. data/lib/llmemory/llm.rb +5 -0
  17. data/lib/llmemory/long_term/episodic/memory.rb +16 -4
  18. data/lib/llmemory/long_term/episodic/storage.rb +11 -4
  19. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +19 -6
  20. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +25 -3
  21. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +22 -5
  22. data/lib/llmemory/long_term/file_based/storage.rb +11 -4
  23. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +16 -10
  24. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +24 -8
  25. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +28 -14
  26. data/lib/llmemory/long_term/graph_based/memory.rb +17 -3
  27. data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
  28. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +47 -21
  29. data/lib/llmemory/long_term/procedural/memory.rb +16 -4
  30. data/lib/llmemory/long_term/procedural/storage.rb +11 -4
  31. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +33 -13
  32. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +25 -4
  33. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +23 -6
  34. data/lib/llmemory/mcp/tools/memory_stats.rb +13 -0
  35. data/lib/llmemory/memory.rb +66 -15
  36. data/lib/llmemory/short_term/checkpoint.rb +5 -2
  37. data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
  38. data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
  39. data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
  40. data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
  41. data/lib/llmemory/short_term/stores.rb +7 -6
  42. data/lib/llmemory/vector_store/active_record_store.rb +30 -3
  43. data/lib/llmemory/vector_store/memory_store.rb +29 -3
  44. data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
  45. data/lib/llmemory/vector_store.rb +4 -3
  46. data/lib/llmemory/version.rb +1 -1
  47. data/lib/llmemory.rb +2 -0
  48. metadata +8 -1
@@ -3,15 +3,19 @@
3
3
  require "json"
4
4
  require "securerandom"
5
5
  require_relative "base"
6
+ require_relative "../../../crypto/field_helpers"
6
7
 
7
8
  module Llmemory
8
9
  module LongTerm
9
10
  module FileBased
10
11
  module Storages
11
12
  class DatabaseStorage < Base
12
- def initialize(database_url: nil)
13
+ include Llmemory::Crypto::FieldHelpers
14
+
15
+ def initialize(database_url: nil, cipher: nil)
13
16
  @database_url = database_url || Llmemory.configuration.database_url
14
17
  @connection = nil
18
+ @cipher = cipher || Llmemory.build_cipher
15
19
  end
16
20
 
17
21
  def save_resource(user_id, text)
@@ -19,7 +23,7 @@ module Llmemory
19
23
  id = "res_#{SecureRandom.hex(8)}"
20
24
  conn.exec_params(
21
25
  "INSERT INTO llmemory_resources (id, user_id, text, created_at) VALUES ($1, $2, $3, $4)",
22
- [id, user_id, text, Time.now.utc.iso8601]
26
+ [id, user_id, enc(text), Time.now.utc.iso8601]
23
27
  )
24
28
  id
25
29
  end
@@ -29,7 +33,7 @@ module Llmemory
29
33
  id = "item_#{SecureRandom.hex(8)}"
30
34
  conn.exec_params(
31
35
  "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, importance, provenance, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)",
32
- [id, user_id, category, content, source_resource_id, importance.to_f, provenance ? JSON.generate(provenance) : nil, Time.now.utc.iso8601]
36
+ [id, user_id, category, enc(content), source_resource_id, importance.to_f, provenance_json(provenance), Time.now.utc.iso8601]
33
37
  )
34
38
  id
35
39
  end
@@ -40,7 +44,7 @@ module Llmemory
40
44
  "SELECT content FROM llmemory_categories WHERE user_id = $1 AND category_name = $2",
41
45
  [user_id, category_name]
42
46
  )
43
- result.any? ? result.first["content"].to_s : ""
47
+ result.any? ? dec(result.first["content"].to_s) : ""
44
48
  end
45
49
 
46
50
  def save_category(user_id, category_name, content)
@@ -52,7 +56,7 @@ module Llmemory
52
56
  ON CONFLICT (user_id, category_name)
53
57
  DO UPDATE SET content = $3, updated_at = $4
54
58
  SQL
55
- [user_id, category_name, content, Time.now.utc.iso8601]
59
+ [user_id, category_name, enc(content), Time.now.utc.iso8601]
56
60
  )
57
61
  true
58
62
  end
@@ -145,7 +149,7 @@ module Llmemory
145
149
  id,
146
150
  user_id,
147
151
  merged_item[:category],
148
- merged_item[:content],
152
+ enc(merged_item[:content]),
149
153
  merged_item[:source_resource_id],
150
154
  created_at
151
155
  ]
@@ -283,7 +287,7 @@ module Llmemory
283
287
  {
284
288
  id: r["id"],
285
289
  category: r["category"],
286
- content: r["content"],
290
+ content: dec(r["content"]),
287
291
  source_resource_id: r["source_resource_id"],
288
292
  importance: (r["importance"] || 0.7).to_f,
289
293
  provenance: parse_provenance(r["provenance"]),
@@ -304,16 +308,28 @@ module Llmemory
304
308
 
305
309
  def parse_provenance(value)
306
310
  return nil if value.nil? || value.to_s.strip.empty?
311
+ return value.transform_keys(&:to_sym) if value.is_a?(Hash)
312
+ return dec_json(value) if value.is_a?(String) && cipher.encrypted?(value)
313
+
307
314
  JSON.parse(value, symbolize_names: true)
308
315
  rescue JSON::ParserError
309
316
  nil
310
317
  end
311
318
 
319
+ def provenance_json(provenance)
320
+ return nil unless provenance
321
+ if cipher.enabled?
322
+ JSON.generate(enc_json(provenance))
323
+ else
324
+ JSON.generate(provenance)
325
+ end
326
+ end
327
+
312
328
  def rows_to_resources(rows)
313
329
  rows.map do |r|
314
330
  {
315
331
  id: r["id"],
316
- text: r["text"],
332
+ text: dec(r["text"]),
317
333
  created_at: Time.parse(r["created_at"])
318
334
  }
319
335
  end
@@ -3,15 +3,19 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require_relative "base"
6
+ require_relative "../../../crypto/field_helpers"
6
7
 
7
8
  module Llmemory
8
9
  module LongTerm
9
10
  module FileBased
10
11
  module Storages
11
12
  class FileStorage < Base
12
- def initialize(base_path: nil)
13
+ include Llmemory::Crypto::FieldHelpers
14
+
15
+ def initialize(base_path: nil, cipher: nil)
13
16
  @base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
14
17
  @base_path = File.expand_path(@base_path)
18
+ @cipher = cipher || Llmemory.build_cipher
15
19
  end
16
20
 
17
21
  def save_resource(user_id, text)
@@ -19,8 +23,8 @@ module Llmemory
19
23
  seq = next_seq(user_id, "resource_id_seq")
20
24
  id = "res_#{seq}"
21
25
  path = resource_path(user_id, id)
22
- data = { text: text, created_at: Time.now.iso8601 }
23
- File.write(path, JSON.generate(data))
26
+ data = { text: enc(text), created_at: Time.now.iso8601 }
27
+ write_encrypted_file(path, data)
24
28
  id
25
29
  end
26
30
 
@@ -32,26 +36,26 @@ module Llmemory
32
36
  data = {
33
37
  id: id,
34
38
  category: category,
35
- content: content,
39
+ content: enc(content),
36
40
  source_resource_id: source_resource_id,
37
41
  importance: importance,
38
- provenance: provenance,
42
+ provenance: provenance ? enc_json(provenance) : nil,
39
43
  created_at: Time.now.iso8601
40
44
  }
41
- File.write(path, JSON.generate(data))
45
+ write_encrypted_file(path, data)
42
46
  id
43
47
  end
44
48
 
45
49
  def load_category(user_id, category_name)
46
50
  path = category_path(user_id, category_name)
47
51
  return "" unless File.file?(path)
48
- File.read(path)
52
+ read_encrypted_text_file(path)
49
53
  end
50
54
 
51
55
  def save_category(user_id, category_name, content)
52
56
  ensure_user_dir(user_id, "categories")
53
57
  path = category_path(user_id, category_name)
54
- File.write(path, content)
58
+ write_encrypted_text_file(path, content)
55
59
  true
56
60
  end
57
61
 
@@ -83,7 +87,8 @@ module Llmemory
83
87
  dir = user_path(user_id, "items")
84
88
  return [] unless Dir.exist?(dir)
85
89
  Dir.children(dir).select { |f| f.end_with?(".json") }.map do |f|
86
- data = JSON.parse(File.read(File.join(dir, f)), symbolize_names: true)
90
+ data = read_encrypted_file(File.join(dir, f))
91
+ data = decrypt_item(data)
87
92
  data[:created_at] = parse_time(data[:created_at])
88
93
  data
89
94
  end.sort_by { |i| i[:created_at] }
@@ -93,9 +98,10 @@ module Llmemory
93
98
  dir = user_path(user_id, "resources")
94
99
  return [] unless Dir.exist?(dir)
95
100
  Dir.children(dir).select { |f| f.end_with?(".json") }.map do |f|
96
- data = JSON.parse(File.read(File.join(dir, f)), symbolize_names: true)
101
+ data = read_encrypted_file(File.join(dir, f))
97
102
  id = File.basename(f, ".json")
98
103
  data[:id] = id
104
+ data[:text] = dec(data[:text] || data["text"])
99
105
  data[:created_at] = parse_time(data[:created_at])
100
106
  data
101
107
  end.sort_by { |r| r[:created_at] }
@@ -113,7 +119,9 @@ module Llmemory
113
119
  id = "item_#{seq}"
114
120
  path = item_path(user_id, id)
115
121
  data = merged_item.merge(id: id).transform_values { |v| v.respond_to?(:iso8601) ? v.iso8601 : v }
116
- File.write(path, JSON.generate(data))
122
+ data[:content] = enc(data[:content]) if data[:content]
123
+ data[:provenance] = enc_json(data[:provenance]) if data[:provenance]
124
+ write_encrypted_file(path, data)
117
125
  end
118
126
 
119
127
  def archive_items(user_id, item_ids)
@@ -127,9 +135,8 @@ module Llmemory
127
135
  def save_daily_log_entry(user_id, date, content)
128
136
  ensure_user_dir(user_id, "memory")
129
137
  path = daily_log_path(user_id, date)
130
- existing = File.file?(path) ? File.read(path) : ""
131
138
  entry = "#{Time.now.strftime('%H:%M')} #{content}\n"
132
- File.write(path, existing + entry)
139
+ write_encrypted_text_file(path, entry, append: File.file?(path))
133
140
  true
134
141
  end
135
142
 
@@ -143,7 +150,7 @@ module Llmemory
143
150
  path = daily_log_path(user_id, d)
144
151
  next unless File.file?(path)
145
152
 
146
- { date: d, content: File.read(path) }
153
+ { date: d, content: read_encrypted_text_file(path) }
147
154
  end
148
155
  end
149
156
 
@@ -224,6 +231,13 @@ module Llmemory
224
231
  return Time.parse(val.to_s) if val
225
232
  Time.now
226
233
  end
234
+
235
+ def decrypt_item(data)
236
+ data[:content] = dec(data[:content] || data["content"])
237
+ prov = data[:provenance] || data["provenance"]
238
+ data[:provenance] = parse_provenance(prov) if prov
239
+ data
240
+ end
227
241
  end
228
242
  end
229
243
  end
@@ -14,9 +14,10 @@ module Llmemory
14
14
  class Memory
15
15
  include Llmemory::MemoryModule
16
16
 
17
- def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil)
17
+ def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil, cipher: nil)
18
18
  @user_id = user_id
19
- @graph_storage = storage || Storages.build
19
+ @cipher = cipher || Llmemory.build_cipher
20
+ @graph_storage = storage || Storages.build(cipher: @cipher)
20
21
  @kg = KnowledgeGraph.new(user_id: user_id, storage: @graph_storage)
21
22
  @conflict_resolver = ConflictResolver.new(@kg)
22
23
  @vector_store = vector_store || build_vector_store
@@ -112,7 +113,7 @@ module Llmemory
112
113
  private
113
114
 
114
115
  def build_vector_store
115
- Llmemory::VectorStore.build(source_type: "edge")
116
+ Llmemory::VectorStore.build(source_type: "edge", cipher: @cipher)
116
117
  end
117
118
 
118
119
  def extract_graph(text)
@@ -161,6 +162,7 @@ module Llmemory
161
162
 
162
163
  edge_text = "#{subject} #{predicate} #{object}"
163
164
  embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(edge_text) : nil
165
+ record_embed_usage(@vector_store) if embedding
164
166
  if embedding && @vector_store.respond_to?(:store)
165
167
  @vector_store.store(id: edge_id, embedding: embedding, metadata: { text: edge_text, created_at: Time.now }, user_id: @user_id)
166
168
  end
@@ -171,8 +173,10 @@ module Llmemory
171
173
  vector_results = []
172
174
  if @vector_store.respond_to?(:search_by_text)
173
175
  vector_results = @vector_store.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
176
+ record_embed_usage(@vector_store)
174
177
  elsif @vector_store.respond_to?(:embed) && @vector_store.respond_to?(:search)
175
178
  emb = @vector_store.embed(query.to_s)
179
+ record_embed_usage(@vector_store)
176
180
  vector_results = @vector_store.search(emb, top_k: top_k, user_id: @user_id)
177
181
  end
178
182
 
@@ -230,6 +234,16 @@ module Llmemory
230
234
  lines << "=== END MEMORIES ==="
231
235
  lines.join("\n")
232
236
  end
237
+
238
+ def record_embed_usage(vector_store)
239
+ return unless vector_store
240
+
241
+ Llmemory::LLM::UsageRecorder.record_embed_from_store(
242
+ user_id: @user_id,
243
+ vector_store: vector_store,
244
+ store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
245
+ )
246
+ end
233
247
  end
234
248
  end
235
249
  end
@@ -7,13 +7,14 @@ module Llmemory
7
7
  module LongTerm
8
8
  module GraphBased
9
9
  module Storages
10
- def self.build(store: nil)
10
+ def self.build(store: nil, cipher: nil)
11
+ resolved_cipher = cipher || Llmemory.build_cipher
11
12
  case (store || Llmemory.configuration.long_term_store).to_s.to_sym
12
13
  when :memory
13
14
  MemoryStorage.new
14
15
  when :active_record, :activerecord
15
16
  require_relative "storages/active_record_storage"
16
- ActiveRecordStorage.new
17
+ ActiveRecordStorage.new(cipher: resolved_cipher)
17
18
  else
18
19
  MemoryStorage.new
19
20
  end
@@ -4,13 +4,17 @@ require_relative "base"
4
4
  require_relative "active_record_models"
5
5
  require_relative "../node"
6
6
  require_relative "../edge"
7
+ require_relative "../../../crypto/field_helpers"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
10
11
  module GraphBased
11
12
  module Storages
12
13
  class ActiveRecordStorage < Base
13
- def initialize
14
+ include Llmemory::Crypto::FieldHelpers
15
+
16
+ def initialize(cipher: nil)
17
+ @cipher = cipher || Llmemory.build_cipher
14
18
  self.class.load_models!
15
19
  end
16
20
 
@@ -26,17 +30,22 @@ module Llmemory
26
30
  rec = if n.id
27
31
  LlmemoryGraphNode.find_by(user_id: user_id, id: n.id)
28
32
  else
29
- LlmemoryGraphNode.find_by(user_id: user_id, entity_type: n.entity_type, name: n.name)
33
+ LlmemoryGraphNode.find_by(
34
+ user_id: user_id,
35
+ entity_type: enc_det(n.entity_type),
36
+ name: enc_det(n.name)
37
+ )
30
38
  end
39
+ stored_props = store_properties(n.properties || {})
31
40
  if rec
32
- rec.update!(properties: n.properties || {}, updated_at: Time.current)
41
+ rec.update!(properties: stored_props, updated_at: Time.current)
33
42
  rec.id
34
43
  else
35
44
  rec = LlmemoryGraphNode.create!(
36
45
  user_id: user_id,
37
- entity_type: n.entity_type.to_s,
38
- name: n.name.to_s,
39
- properties: n.properties || {}
46
+ entity_type: enc_det(n.entity_type.to_s),
47
+ name: enc_det(n.name.to_s),
48
+ properties: stored_props
40
49
  )
41
50
  rec.id
42
51
  end
@@ -48,13 +57,17 @@ module Llmemory
48
57
  end
49
58
 
50
59
  def find_node_by_name(user_id, entity_type, name)
51
- rec = LlmemoryGraphNode.find_by(user_id: user_id, entity_type: entity_type.to_s, name: name.to_s)
60
+ rec = LlmemoryGraphNode.find_by(
61
+ user_id: user_id,
62
+ entity_type: enc_det(entity_type.to_s),
63
+ name: enc_det(name.to_s)
64
+ )
52
65
  record_to_node(rec) if rec
53
66
  end
54
67
 
55
68
  def list_nodes(user_id, entity_type: nil, limit: nil, offset: nil)
56
69
  scope = LlmemoryGraphNode.where(user_id: user_id)
57
- scope = scope.where(entity_type: entity_type) if entity_type
70
+ scope = scope.where(entity_type: enc_det(entity_type.to_s)) if entity_type
58
71
  scope = scope.limit(limit) if limit && limit.to_i.positive?
59
72
  scope = scope.offset(offset) if offset && offset.to_i.positive?
60
73
  scope.map { |r| record_to_node(r) }
@@ -64,24 +77,23 @@ module Llmemory
64
77
  e = edge.is_a?(Edge) ? edge : Edge.from_h(edge.to_h)
65
78
  rec = if e.id && e.id.is_a?(Integer)
66
79
  LlmemoryGraphEdge.find_by(user_id: user_id, id: e.id)
67
- else
68
- nil
69
80
  end
81
+ stored_props = store_properties(e.properties || {})
70
82
  if rec
71
83
  rec.update!(
72
84
  subject_id: e.subject_id,
73
- predicate: e.predicate,
85
+ predicate: enc_det(e.predicate),
74
86
  object_id: e.target_id,
75
- properties: e.properties || {}
87
+ properties: stored_props
76
88
  )
77
89
  rec.id
78
90
  else
79
91
  rec = LlmemoryGraphEdge.create!(
80
92
  user_id: user_id,
81
93
  subject_id: e.subject_id,
82
- predicate: e.predicate,
94
+ predicate: enc_det(e.predicate),
83
95
  object_id: e.target_id,
84
- properties: e.properties || {}
96
+ properties: stored_props
85
97
  )
86
98
  rec.id
87
99
  end
@@ -91,7 +103,7 @@ module Llmemory
91
103
  scope = LlmemoryGraphEdge.where(user_id: user_id)
92
104
  scope = scope.where(archived_at: nil) unless include_archived
93
105
  scope = scope.where(subject_id: subject_id) if subject_id
94
- scope = scope.where(predicate: predicate) if predicate
106
+ scope = scope.where(predicate: enc_det(predicate.to_s)) if predicate
95
107
  scope = scope.where(object_id: object_id) if object_id
96
108
  scope.map { |r| record_to_edge(r) }
97
109
  end
@@ -110,7 +122,7 @@ module Llmemory
110
122
  def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil, offset: nil)
111
123
  scope = LlmemoryGraphEdge.where(user_id: user_id, archived_at: nil)
112
124
  scope = scope.where(subject_id: subject_id) if subject_id
113
- scope = scope.where(predicate: predicate) if predicate
125
+ scope = scope.where(predicate: enc_det(predicate.to_s)) if predicate
114
126
  scope = scope.order(created_at: :desc) if limit && limit.to_i.positive?
115
127
  scope = scope.limit(limit) if limit && limit.to_i.positive?
116
128
  scope = scope.offset(offset) if offset && offset.to_i.positive?
@@ -153,13 +165,27 @@ module Llmemory
153
165
 
154
166
  private
155
167
 
168
+ def store_properties(props)
169
+ return props || {} unless cipher.enabled?
170
+ return {} if props.nil? || props.empty?
171
+
172
+ enc_json(props)
173
+ end
174
+
175
+ def load_properties(raw)
176
+ return raw || {} if raw.nil? || raw == {}
177
+ return dec_json(raw) if raw.is_a?(String) && cipher.encrypted?(raw)
178
+
179
+ raw
180
+ end
181
+
156
182
  def record_to_node(r)
157
183
  Node.new(
158
184
  id: r.id,
159
185
  user_id: r.user_id,
160
- entity_type: r.entity_type,
161
- name: r.name,
162
- properties: r.properties || {},
186
+ entity_type: dec(r.entity_type),
187
+ name: dec(r.name),
188
+ properties: load_properties(r.properties),
163
189
  created_at: r.created_at,
164
190
  updated_at: r.updated_at
165
191
  )
@@ -170,9 +196,9 @@ module Llmemory
170
196
  id: r.id,
171
197
  user_id: r.user_id,
172
198
  subject_id: r.subject_id,
173
- predicate: r.predicate,
199
+ predicate: dec(r.predicate),
174
200
  target_id: r.object_id,
175
- properties: r.properties || {},
201
+ properties: load_properties(r.properties),
176
202
  created_at: r.created_at,
177
203
  archived_at: r.archived_at
178
204
  )
@@ -22,9 +22,10 @@ module Llmemory
22
22
 
23
23
  attr_reader :user_id, :storage
24
24
 
25
- def initialize(user_id:, storage: nil, vector_store: nil)
25
+ def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil)
26
26
  @user_id = user_id
27
- @storage = storage || Storages.build
27
+ @cipher = cipher || Llmemory.build_cipher
28
+ @storage = storage || Storages.build(cipher: @cipher)
28
29
  @vector_store = vector_store
29
30
  @vector_explicit = !vector_store.nil?
30
31
  end
@@ -131,7 +132,7 @@ module Llmemory
131
132
  if @vector_explicit
132
133
  @vector_store
133
134
  elsif Llmemory.configuration.procedural_vector_enabled
134
- @vector_store ||= Llmemory::VectorStore.build(source_type: "skill")
135
+ @vector_store ||= Llmemory::VectorStore.build(source_type: "skill", cipher: @cipher)
135
136
  end
136
137
  end
137
138
 
@@ -140,6 +141,7 @@ module Llmemory
140
141
  vs = vector_store
141
142
  return if vs.nil? || text.to_s.strip.empty?
142
143
  embedding = vs.embed(text)
144
+ record_embed_usage(vs)
143
145
  return unless embedding
144
146
  vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
145
147
  rescue StandardError
@@ -147,7 +149,9 @@ module Llmemory
147
149
  end
148
150
 
149
151
  def vector_candidates(query, top_k, vs)
150
- vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
152
+ results = vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
153
+ record_embed_usage(vs)
154
+ results.filter_map do |r|
151
155
  raw = @storage.get_skill(@user_id, r[:id] || r["id"])
152
156
  raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
153
157
  end
@@ -177,6 +181,14 @@ module Llmemory
177
181
  end
178
182
  by_id.values.sort_by { |c| -c[:score].to_f }.first(top_k)
179
183
  end
184
+
185
+ def record_embed_usage(vector_store)
186
+ Llmemory::LLM::UsageRecorder.record_embed_from_store(
187
+ user_id: @user_id,
188
+ vector_store: vector_store,
189
+ store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
190
+ )
191
+ end
180
192
  end
181
193
  end
182
194
  end
@@ -12,17 +12,24 @@ module Llmemory
12
12
  Storage = Storages::MemoryStorage
13
13
 
14
14
  module Storages
15
- def self.build(store: nil, base_path: nil, database_url: nil)
15
+ def self.build(store: nil, base_path: nil, database_url: nil, cipher: nil)
16
+ resolved_cipher = cipher || Llmemory.build_cipher
16
17
  case (store || Llmemory.configuration.long_term_store).to_s.to_sym
17
18
  when :memory
18
19
  MemoryStorage.new
19
20
  when :file
20
- FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
21
+ FileStorage.new(
22
+ base_path: base_path || Llmemory.configuration.long_term_storage_path,
23
+ cipher: resolved_cipher
24
+ )
21
25
  when :postgres, :database
22
- DatabaseStorage.new(database_url: database_url || Llmemory.configuration.database_url)
26
+ DatabaseStorage.new(
27
+ database_url: database_url || Llmemory.configuration.database_url,
28
+ cipher: resolved_cipher
29
+ )
23
30
  when :active_record, :activerecord
24
31
  require_relative "storages/active_record_storage"
25
- ActiveRecordStorage.new
32
+ ActiveRecordStorage.new(cipher: resolved_cipher)
26
33
  else
27
34
  MemoryStorage.new
28
35
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
6
  require_relative "base"
7
+ require_relative "../../../crypto/field_helpers"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
@@ -13,7 +14,10 @@ module Llmemory
13
14
  # auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
14
15
  # handles. Mirrors the file-based ActiveRecordStorage pattern.
15
16
  class ActiveRecordStorage < Base
16
- def initialize
17
+ include Llmemory::Crypto::FieldHelpers
18
+
19
+ def initialize(cipher: nil)
20
+ @cipher = cipher || Llmemory.build_cipher
17
21
  self.class.load_models!
18
22
  end
19
23
 
@@ -30,42 +34,50 @@ module Llmemory
30
34
  data["created_at"] ||= Time.now.utc.iso8601
31
35
  rec = LlmemorySkill.find_or_initialize_by(id: id)
32
36
  rec.user_id = user_id
33
- rec.data = data
34
- rec.search_text = searchable_text(data)
37
+ rec.data = cipher.enabled? ? enc_json(data) : data
38
+ rec.search_text = enc(searchable_text(data))
35
39
  rec.created_at ||= Time.current
36
40
  rec.save!
37
41
  id
38
42
  end
39
43
 
40
44
  def get_skill(user_id, id)
41
- LlmemorySkill.find_by(user_id: user_id, id: id)&.data
45
+ rec = LlmemorySkill.find_by(user_id: user_id, id: id)
46
+ return nil unless rec
47
+
48
+ decode_data(rec.data)
42
49
  end
43
50
 
44
51
  def list_skills(user_id, limit: nil, offset: nil)
45
52
  scope = LlmemorySkill.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
46
53
  scope = scope.limit(limit) if limit && limit.to_i.positive?
47
54
  scope = scope.offset(offset) if offset && offset.to_i.positive?
48
- scope.map(&:data)
55
+ scope.map { |r| decode_data(r.data) }
49
56
  end
50
57
 
51
58
  def search_skills(user_id, query)
52
59
  token_scope(LlmemorySkill.where(user_id: user_id, archived_at: nil), "search_text", query)
53
- .order(created_at: :desc).map(&:data)
60
+ .order(created_at: :desc).map { |r| decode_data(r.data) }
54
61
  end
55
62
 
56
63
  def find_skills_by_name(user_id, name)
57
- LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
64
+ if cipher.enabled?
65
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).map { |r| decode_data(r.data) }
66
+ .select { |s| (s[:name] || s["name"]).to_s == name.to_s }
67
+ else
68
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
69
+ end
58
70
  end
59
71
 
60
72
  def record_outcome(user_id, skill_id, success:)
61
73
  rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
62
74
  return nil unless rec
63
- data = rec.data || {}
64
- key = success ? "success_count" : "failure_count"
75
+ data = decode_data(rec.data) || {}
76
+ key = success ? :success_count : :failure_count
65
77
  data[key] = (data[key] || 0).to_i + 1
66
- data["updated_at"] = Time.now.utc.iso8601
67
- rec.data = data
68
- rec.search_text = searchable_text(data)
78
+ data[:updated_at] = Time.now.utc.iso8601
79
+ rec.data = cipher.enabled? ? enc_json(data) : data
80
+ rec.search_text = enc(searchable_text(data))
69
81
  rec.save!
70
82
  data
71
83
  end
@@ -105,7 +117,15 @@ module Llmemory
105
117
  end
106
118
 
107
119
  def searchable_text(data)
108
- [data["name"], data["description"], data["body"]].compact.join("\n")
120
+ h = data.is_a?(Hash) ? data : {}
121
+ [h["name"] || h[:name], h["description"] || h[:description], h["body"] || h[:body]].compact.join("\n")
122
+ end
123
+
124
+ def decode_data(raw)
125
+ return raw.transform_keys(&:to_sym) if raw.is_a?(Hash)
126
+ return dec_json(raw) if raw.is_a?(String) && cipher.encrypted?(raw)
127
+
128
+ raw
109
129
  end
110
130
  end
111
131
  end