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
@@ -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,9 +14,12 @@ module Llmemory
13
14
  # (plus id/user_id/created_at and a denormalized search_text), mirroring
14
15
  # the file-based DatabaseStorage pattern.
15
16
  class DatabaseStorage < Base
16
- def initialize(database_url: nil)
17
+ include Llmemory::Crypto::FieldHelpers
18
+
19
+ def initialize(database_url: nil, cipher: nil)
17
20
  @database_url = database_url || Llmemory.configuration.database_url
18
21
  @connection = nil
22
+ @cipher = cipher || Llmemory.build_cipher
19
23
  end
20
24
 
21
25
  def save_skill(user_id, skill)
@@ -27,7 +31,7 @@ module Llmemory
27
31
  "INSERT INTO llmemory_skills (id, user_id, data, search_text, created_at) " \
28
32
  "VALUES ($1, $2, $3::jsonb, $4, $5) " \
29
33
  "ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
30
- [id, user_id, JSON.generate(data), searchable_text(data), created_at_value(data)]
34
+ [id, user_id, store_data(data), enc(searchable_text(data)), created_at_value(data)]
31
35
  )
32
36
  id
33
37
  end
@@ -72,7 +76,7 @@ module Llmemory
72
76
  data[:updated_at] = Time.now.utc.iso8601
73
77
  conn.exec_params(
74
78
  "UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
75
- [user_id, skill_id, JSON.generate(data), searchable_text(data)]
79
+ [user_id, skill_id, store_data(data), enc(searchable_text(data))]
76
80
  )
77
81
  data
78
82
  end
@@ -144,11 +148,28 @@ module Llmemory
144
148
  end
145
149
 
146
150
  def parse_data(value)
147
- JSON.parse(value.to_s, symbolize_names: true)
151
+ if value.is_a?(Hash)
152
+ return value.transform_keys(&:to_sym)
153
+ end
154
+
155
+ str = value.to_s
156
+ if cipher.encrypted?(str)
157
+ cipher.decrypt_json(str)
158
+ else
159
+ JSON.parse(str, symbolize_names: true)
160
+ end
148
161
  rescue JSON::ParserError
149
162
  {}
150
163
  end
151
164
 
165
+ def store_data(data)
166
+ if cipher.enabled?
167
+ JSON.generate(enc_json(data))
168
+ else
169
+ JSON.generate(data)
170
+ end
171
+ end
172
+
152
173
  def symbolize(hash)
153
174
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
154
175
  end
@@ -4,22 +4,26 @@ require "fileutils"
4
4
  require "json"
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
10
11
  module Procedural
11
12
  module Storages
12
13
  class FileStorage < Base
13
- def initialize(base_path: nil)
14
+ include Llmemory::Crypto::FieldHelpers
15
+
16
+ def initialize(base_path: nil, cipher: nil)
14
17
  @base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
15
18
  @base_path = File.expand_path(@base_path)
19
+ @cipher = cipher || Llmemory.build_cipher
16
20
  end
17
21
 
18
22
  def save_skill(user_id, skill)
19
23
  id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
20
24
  data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
21
25
  data["created_at"] ||= Time.now.iso8601(6)
22
- File.write(skill_path(user_id, id), JSON.generate(data))
26
+ write_skill_file(skill_path(user_id, id), data)
23
27
  id
24
28
  end
25
29
 
@@ -50,7 +54,7 @@ module Llmemory
50
54
  key = success ? :success_count : :failure_count
51
55
  skill[key] = (skill[key] || 0).to_i + 1
52
56
  skill[:updated_at] = Time.now.iso8601(6)
53
- File.write(skill_path(user_id, skill_id), JSON.generate(stringify_for_json(skill)))
57
+ write_skill_file(skill_path(user_id, skill_id), stringify_for_json(skill))
54
58
  skill
55
59
  end
56
60
 
@@ -71,10 +75,10 @@ module Llmemory
71
75
  Array(ids).map(&:to_s).count do |id|
72
76
  path = skill_path(user_id, id)
73
77
  next false unless File.file?(path)
74
- data = JSON.parse(File.read(path))
78
+ data = load_skill_raw(path)
75
79
  next false if data["archived_at"]
76
80
  data["archived_at"] = Time.now.iso8601
77
- File.write(path, JSON.generate(data))
81
+ write_skill_file(path, data)
78
82
  true
79
83
  end
80
84
  end
@@ -103,7 +107,9 @@ module Llmemory
103
107
  end
104
108
 
105
109
  def load_skill(path)
106
- data = JSON.parse(File.read(path), symbolize_names: true)
110
+ data = load_skill_raw(path)
111
+ return nil unless data
112
+
107
113
  data[:created_at] = parse_time(data[:created_at])
108
114
  data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
109
115
  data
@@ -111,6 +117,17 @@ module Llmemory
111
117
  nil
112
118
  end
113
119
 
120
+ def load_skill_raw(path)
121
+ raw = File.read(path)
122
+ json = cipher.enabled? && cipher.encrypted?(raw) ? cipher.decrypt(raw) : raw
123
+ JSON.parse(json, symbolize_names: true)
124
+ end
125
+
126
+ def write_skill_file(path, data)
127
+ payload = JSON.generate(data)
128
+ File.write(path, cipher.enabled? ? cipher.encrypt(payload) : payload)
129
+ end
130
+
114
131
  def skill_text(skill)
115
132
  [skill[:name], skill[:description], skill[:body]].compact.join("\n")
116
133
  end
@@ -52,6 +52,8 @@ module Llmemory
52
52
  stats[:long_term] = { error: e.message }
53
53
  end
54
54
 
55
+ stats[:llm_usage] = Llmemory::LLM::UsageLedger.new(store: store).totals(user_id)
56
+
55
57
  ::MCP::Tool::Response.new([{
56
58
  type: "text",
57
59
  text: format_stats(stats)
@@ -102,8 +104,19 @@ module Llmemory
102
104
  output << " Resources: #{stats[:long_term][:resources]}"
103
105
  end
104
106
 
107
+ output << ""
108
+ output << Llmemory::LLM::UsageLedger.format_text(stats[:llm_usage] || default_llm_usage)
109
+
105
110
  output.join("\n")
106
111
  end
112
+
113
+ def default_llm_usage
114
+ {
115
+ invoke: { input_tokens: 0, output_tokens: 0, total_tokens: 0, calls: 0 },
116
+ embed: { total_tokens: 0, calls: 0 },
117
+ updated_at: nil
118
+ }
119
+ end
107
120
  end
108
121
  end
109
122
  end
@@ -10,47 +10,76 @@ module Llmemory
10
10
  DEFAULT_SESSION_ID = "default"
11
11
  STATE_KEY_MESSAGES = :messages
12
12
 
13
- def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil)
13
+ def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil, encryption_key: :inherit)
14
14
  @user_id = user_id
15
15
  @session_id = session_id
16
- @checkpoint = checkpoint || ShortTerm::Checkpoint.new(user_id: user_id, session_id: session_id)
16
+ resolved_key = encryption_key == :inherit ? nil : encryption_key
17
+ @cipher = Llmemory.build_cipher(resolved_key)
18
+ if checkpoint
19
+ @checkpoint = checkpoint
20
+ @short_term_store = checkpoint.store
21
+ else
22
+ @short_term_store = build_short_term_store(@cipher)
23
+ @checkpoint = ShortTerm::Checkpoint.new(
24
+ user_id: user_id,
25
+ session_id: session_id,
26
+ store: @short_term_store,
27
+ cipher: @cipher
28
+ )
29
+ end
17
30
  @working_memory = working_memory
18
31
  @episodic = episodic
19
32
  @procedural = procedural
20
- @llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
33
+ @api_key = api_key unless api_key.to_s.empty?
21
34
  type = long_term_type || Llmemory.configuration.long_term_type || :file_based
22
35
  @long_term = long_term || build_long_term(type)
23
- @retrieval_engine = retrieval_engine || Retrieval::Engine.new(@long_term, llm: @llm)
36
+ @retrieval_engine = retrieval_engine || Retrieval::Engine.new(
37
+ @long_term,
38
+ llm: tracked_llm_client,
39
+ feedback: Retrieval::FeedbackStore.new(store: @short_term_store)
40
+ )
24
41
  end
25
42
 
26
43
  # Structured working memory for this session (CoALA working memory),
27
44
  # parallel to the message checkpoint. Lazily built.
28
45
  def working_memory
29
- @working_memory ||= WorkingMemory.new(user_id: @user_id, session_id: @session_id)
46
+ @working_memory ||= WorkingMemory.new(
47
+ user_id: @user_id,
48
+ session_id: @session_id,
49
+ store: build_short_term_store(@cipher)
50
+ )
30
51
  end
31
52
 
32
53
  # Episodic long-term memory (CoALA): records and retrieves agent trajectories.
33
54
  # Additive — coexists with the semantic store (file/graph). Lazily built.
34
55
  def episodic
35
- @episodic ||= LongTerm::Episodic::Memory.new(user_id: @user_id)
56
+ @episodic ||= LongTerm::Episodic::Memory.new(
57
+ user_id: @user_id,
58
+ storage: LongTerm::Episodic::Storages.build(cipher: @cipher),
59
+ cipher: @cipher
60
+ )
36
61
  end
37
62
 
38
63
  # Procedural long-term memory (Voyager-style skill library). Lazily built.
39
64
  def procedural
40
- @procedural ||= LongTerm::Procedural::Memory.new(user_id: @user_id)
65
+ @procedural ||= LongTerm::Procedural::Memory.new(
66
+ user_id: @user_id,
67
+ storage: LongTerm::Procedural::Storages.build(cipher: @cipher),
68
+ cipher: @cipher
69
+ )
41
70
  end
42
71
 
43
72
  # Reflects over recent episodes and writes distilled insights to the
44
73
  # semantic store (file/graph) with provenance back to source episodes.
45
74
  def reflect!(window: 10, category: "insights")
46
- Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: @llm)
75
+ Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: tracked_llm_client)
47
76
  .reflect(window: window, category: category)
48
77
  end
49
78
 
50
79
  # Reasoning action: render a prompt from working memory, call the LLM, write
51
80
  # the result back. Composable; does not touch long-term memory.
52
81
  def reason(template:, into: Actions::Reason::DEFAULT_SLOT, parse: nil)
53
- Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: @llm)
82
+ Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: tracked_llm_client)
54
83
  end
55
84
 
56
85
  # Mines recent episodes for reusable skills (Voyager-style). Human-in-the-loop
@@ -58,7 +87,7 @@ module Llmemory
58
87
  # `auto_register: true`, registers them in procedural memory (with provenance
59
88
  # back to the source episodes) and returns the new skill ids.
60
89
  def mine_skills!(window: SkillMining::Miner::DEFAULT_WINDOW, outcomes: nil, auto_register: false)
61
- SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: @llm)
90
+ SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: tracked_llm_client)
62
91
  .mine(window: window, outcomes: outcomes, auto_register: auto_register)
63
92
  end
64
93
 
@@ -68,7 +97,7 @@ module Llmemory
68
97
  def maintain!(**opts)
69
98
  Maintenance::CognitivePass.run!(
70
99
  @user_id,
71
- memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm: @llm,
100
+ memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm: tracked_llm_client,
72
101
  **opts
73
102
  )
74
103
  end
@@ -222,6 +251,10 @@ module Llmemory
222
251
  @user_id
223
252
  end
224
253
 
254
+ def llm_usage
255
+ Llmemory::LLM::UsageLedger.new(store: @short_term_store).totals(@user_id)
256
+ end
257
+
225
258
  private
226
259
 
227
260
  def summarize_messages(msgs)
@@ -240,7 +273,16 @@ module Llmemory
240
273
  end
241
274
 
242
275
  def llm_client
243
- @llm ||= Llmemory::LLM.client
276
+ tracked_llm_client
277
+ end
278
+
279
+ def tracked_llm_client
280
+ @tracked_llm_client ||= Llmemory::LLM::TrackingClient.new(
281
+ nil,
282
+ user_id: @user_id,
283
+ store: @short_term_store,
284
+ api_key: @api_key
285
+ )
244
286
  end
245
287
 
246
288
  def flush_memory_before_compaction!(msgs)
@@ -316,19 +358,28 @@ module Llmemory
316
358
  end
317
359
 
318
360
  def build_long_term(long_term_type)
319
- llm_opts = @llm ? { llm: @llm } : {}
361
+ llm_opts = { llm: tracked_llm_client }
320
362
  case long_term_type.to_s.to_sym
321
363
  when :graph_based
322
364
  LongTerm::GraphBased::Memory.new(
323
365
  user_id: @user_id,
324
- storage: LongTerm::GraphBased::Storages.build,
366
+ storage: LongTerm::GraphBased::Storages.build(cipher: @cipher),
367
+ cipher: @cipher,
325
368
  **llm_opts
326
369
  )
327
370
  else
328
- LongTerm::FileBased::Memory.new(user_id: @user_id, storage: LongTerm::FileBased::Storages.build, **llm_opts)
371
+ LongTerm::FileBased::Memory.new(
372
+ user_id: @user_id,
373
+ storage: LongTerm::FileBased::Storages.build(cipher: @cipher),
374
+ **llm_opts
375
+ )
329
376
  end
330
377
  end
331
378
 
379
+ def build_short_term_store(cipher)
380
+ ShortTerm::Stores.build(cipher: cipher)
381
+ end
382
+
332
383
  def save_state(messages:, last_flush_at: nil, last_compact_at: nil)
333
384
  state = { STATE_KEY_MESSAGES => messages, last_activity_at: Time.now }
334
385
  state[:last_flush_at] = last_flush_at if last_flush_at
@@ -7,12 +7,15 @@ module Llmemory
7
7
  class Checkpoint
8
8
  DEFAULT_SESSION_ID = "default"
9
9
 
10
- def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil)
10
+ def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil, cipher: nil)
11
11
  @user_id = user_id
12
12
  @session_id = session_id
13
+ @cipher = cipher
13
14
  @store = store || build_store
14
15
  end
15
16
 
17
+ attr_reader :store
18
+
16
19
  def save_state(state)
17
20
  @store.save(@user_id, @session_id, state)
18
21
  end
@@ -28,7 +31,7 @@ module Llmemory
28
31
  private
29
32
 
30
33
  def build_store
31
- Stores.build
34
+ Stores.build(cipher: @cipher)
32
35
  end
33
36
  end
34
37
  end
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../../crypto/field_helpers"
4
5
 
5
6
  module Llmemory
6
7
  module ShortTerm
7
8
  module Stores
8
9
  class ActiveRecordStore < Base
9
- def initialize
10
+ include Llmemory::Crypto::FieldHelpers
11
+
12
+ def initialize(cipher: nil)
13
+ @cipher = cipher || Llmemory.build_cipher
10
14
  self.class.load_model!
11
15
  end
12
16
 
@@ -22,7 +26,7 @@ module Llmemory
22
26
  user_id: user_id,
23
27
  session_id: session_id
24
28
  )
25
- record.state = state
29
+ record.state = cipher.enabled? ? serialize_state(state) : state
26
30
  record.updated_at = Time.current
27
31
  record.save!
28
32
  true
@@ -34,8 +38,13 @@ module Llmemory
34
38
  session_id: session_id
35
39
  )
36
40
  return nil unless record
41
+
37
42
  raw = record.state
38
- raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : deserialize(raw)
43
+ if raw.is_a?(Hash)
44
+ raw.transform_keys(&:to_sym)
45
+ else
46
+ deserialize_state(raw)
47
+ end
39
48
  end
40
49
 
41
50
  def delete(user_id, session_id)
@@ -53,13 +62,6 @@ module Llmemory
53
62
  def list_sessions(user_id:)
54
63
  Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.where(user_id: user_id).pluck(:session_id)
55
64
  end
56
-
57
- private
58
-
59
- def deserialize(data)
60
- return data if data.is_a?(Hash)
61
- JSON.parse(data.to_s, symbolize_names: true)
62
- end
63
65
  end
64
66
  end
65
67
  end
@@ -6,7 +6,7 @@ module Llmemory
6
6
  module ShortTerm
7
7
  module Stores
8
8
  class MemoryStore < Base
9
- def initialize
9
+ def initialize(cipher: nil)
10
10
  @store = {}
11
11
  end
12
12
 
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../../crypto/field_helpers"
4
5
 
5
6
  module Llmemory
6
7
  module ShortTerm
7
8
  module Stores
8
9
  class PostgresStore < Base
9
- def initialize(database_url: nil)
10
+ include Llmemory::Crypto::FieldHelpers
11
+
12
+ def initialize(database_url: nil, cipher: nil)
10
13
  @database_url = database_url || Llmemory.configuration.database_url
11
14
  @connection = nil
15
+ @cipher = cipher || Llmemory.build_cipher
12
16
  end
13
17
 
14
18
  def save(user_id, session_id, state)
@@ -81,13 +85,15 @@ module Llmemory
81
85
  end
82
86
 
83
87
  def serialize(state)
84
- require "json"
85
- JSON.generate(state)
88
+ payload = serialize_state(state)
89
+ cipher.enabled? ? JSON.generate(payload) : payload
86
90
  end
87
91
 
88
92
  def deserialize(data)
89
- require "json"
90
- JSON.parse(data, symbolize_names: true)
93
+ if data.is_a?(String) && !cipher.encrypted?(data)
94
+ data = JSON.parse(data)
95
+ end
96
+ deserialize_state(data)
91
97
  end
92
98
  end
93
99
  end
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../../crypto/field_helpers"
4
5
 
5
6
  module Llmemory
6
7
  module ShortTerm
7
8
  module Stores
8
9
  class RedisStore < Base
9
- def initialize(redis_url: nil)
10
+ include Llmemory::Crypto::FieldHelpers
11
+
12
+ def initialize(redis_url: nil, cipher: nil)
10
13
  @redis_url = redis_url || Llmemory.configuration.redis_url
11
14
  @redis = nil
15
+ @cipher = cipher || Llmemory.build_cipher
12
16
  end
13
17
 
14
18
  def save(user_id, session_id, state)
@@ -50,13 +54,11 @@ module Llmemory
50
54
  end
51
55
 
52
56
  def serialize(state)
53
- require "json"
54
- JSON.generate(state)
57
+ serialize_state(state)
55
58
  end
56
59
 
57
60
  def deserialize(data)
58
- require "json"
59
- JSON.parse(data, symbolize_names: true)
61
+ deserialize_state(data)
60
62
  end
61
63
  end
62
64
  end
@@ -10,16 +10,17 @@ module Llmemory
10
10
  module Stores
11
11
  # Single source of truth for selecting a short-term store backend.
12
12
  # Shared by Checkpoint, SessionLifecycle and WorkingMemory.
13
- def self.build(store_type = nil)
13
+ def self.build(store_type = nil, cipher: nil)
14
+ resolved_cipher = cipher || Llmemory.build_cipher
14
15
  case (store_type || Llmemory.configuration.short_term_store).to_sym
15
- when :memory then MemoryStore.new
16
- when :redis then RedisStore.new
17
- when :postgres then PostgresStore.new
16
+ when :memory then MemoryStore.new(cipher: resolved_cipher)
17
+ when :redis then RedisStore.new(cipher: resolved_cipher)
18
+ when :postgres then PostgresStore.new(cipher: resolved_cipher)
18
19
  when :active_record, :activerecord
19
20
  require_relative "stores/active_record_store"
20
- ActiveRecordStore.new
21
+ ActiveRecordStore.new(cipher: resolved_cipher)
21
22
  else
22
- MemoryStore.new
23
+ MemoryStore.new(cipher: resolved_cipher)
23
24
  end
24
25
  end
25
26
  end
@@ -7,10 +7,11 @@ module Llmemory
7
7
  # Persists embeddings in llmemory_embeddings (pgvector).
8
8
  # Use when long_term_store is :active_record so hybrid search finds persisted embeddings.
9
9
  class ActiveRecordStore < Base
10
- def initialize(embedding_provider: nil, source_type: "edge")
10
+ def initialize(embedding_provider: nil, source_type: "edge", cipher: nil)
11
11
  self.class.load_model!
12
12
  @embedding_provider = embedding_provider
13
13
  @source_type = source_type.to_s
14
+ @cipher = cipher || Llmemory.build_cipher
14
15
  end
15
16
 
16
17
  def self.load_model!
@@ -25,6 +26,12 @@ module Llmemory
25
26
  @embedding_provider.embed(text)
26
27
  end
27
28
 
29
+ def last_usage
30
+ return @embedding_provider.last_usage if @embedding_provider&.respond_to?(:last_usage)
31
+
32
+ Llmemory::LLM::Usage.zero
33
+ end
34
+
28
35
  def store(id:, embedding:, metadata: {}, user_id: nil)
29
36
  return id if user_id.nil? || user_id.to_s.empty?
30
37
  text_content = (metadata || {}).dig("text") || (metadata || {}).dig(:text)
@@ -34,7 +41,7 @@ module Llmemory
34
41
  source_id: id.to_s
35
42
  )
36
43
  rec.embedding = embedding.to_a.map(&:to_f)
37
- rec.text_content = text_content
44
+ rec.text_content = encrypt_text_content(text_content)
38
45
  rec.save!
39
46
  id
40
47
  end
@@ -58,7 +65,11 @@ module Llmemory
58
65
  {
59
66
  id: r.source_id,
60
67
  score: score,
61
- metadata: { "text" => r.text_content, "created_at" => r.created_at, "user_id" => r.user_id }
68
+ metadata: {
69
+ "text" => decrypt_text_content(r.text_content),
70
+ "created_at" => r.created_at,
71
+ "user_id" => r.user_id
72
+ }
62
73
  }
63
74
  end
64
75
  end
@@ -69,6 +80,22 @@ module Llmemory
69
80
  query_embedding = @embedding_provider.embed(query_text)
70
81
  search(query_embedding, top_k: top_k, user_id: user_id)
71
82
  end
83
+
84
+ private
85
+
86
+ def encrypt_text_content(text)
87
+ return text if text.nil? || text.to_s.empty?
88
+ return text unless @cipher.enabled?
89
+
90
+ @cipher.encrypt(text.to_s)
91
+ end
92
+
93
+ def decrypt_text_content(text)
94
+ return text if text.nil?
95
+ return text unless text.is_a?(String) && @cipher.enabled? && @cipher.encrypted?(text)
96
+
97
+ @cipher.decrypt(text)
98
+ end
72
99
  end
73
100
  end
74
101
  end
@@ -5,9 +5,10 @@ require_relative "base"
5
5
  module Llmemory
6
6
  module VectorStore
7
7
  class MemoryStore < Base
8
- def initialize(embedding_provider: nil)
8
+ def initialize(embedding_provider: nil, cipher: nil)
9
9
  @entries = {}
10
10
  @embedding_provider = embedding_provider
11
+ @cipher = cipher || Llmemory.build_cipher
11
12
  end
12
13
 
13
14
  def embed(text)
@@ -15,9 +16,21 @@ module Llmemory
15
16
  @embedding_provider.embed(text)
16
17
  end
17
18
 
19
+ def last_usage
20
+ return @embedding_provider.last_usage if @embedding_provider&.respond_to?(:last_usage)
21
+
22
+ Llmemory::LLM::Usage.zero
23
+ end
24
+
18
25
  def store(id:, embedding:, metadata: {}, user_id: nil)
19
26
  key = user_id ? "#{user_id}:#{id}" : id.to_s
20
- @entries[key] = { embedding: embedding.to_a.map(&:to_f), metadata: (metadata || {}).merge("user_id" => user_id) }
27
+ meta = (metadata || {}).dup
28
+ if meta["text"] && @cipher.enabled?
29
+ meta["text"] = @cipher.encrypt(meta["text"].to_s)
30
+ elsif meta[:text] && @cipher.enabled?
31
+ meta[:text] = @cipher.encrypt(meta[:text].to_s)
32
+ end
33
+ @entries[key] = { embedding: embedding.to_a.map(&:to_f), metadata: meta.merge("user_id" => user_id) }
21
34
  id
22
35
  end
23
36
 
@@ -27,7 +40,7 @@ module Llmemory
27
40
  entries = user_id ? @entries.select { |k, _| k.to_s.start_with?("#{user_id}:") } : @entries
28
41
  scores = entries.map do |id, data|
29
42
  sim = cosine_similarity(query, data[:embedding])
30
- { id: id, score: sim, metadata: data[:metadata] }
43
+ { id: id, score: sim, metadata: decrypt_metadata(data[:metadata]) }
31
44
  end
32
45
  scores.sort_by { |s| -s[:score] }.first(top_k)
33
46
  end
@@ -40,6 +53,19 @@ module Llmemory
40
53
 
41
54
  private
42
55
 
56
+ def decrypt_metadata(meta)
57
+ return meta unless meta.is_a?(Hash) && @cipher.enabled?
58
+
59
+ out = meta.dup
60
+ text = out["text"] || out[:text]
61
+ if text.is_a?(String) && @cipher.encrypted?(text)
62
+ decrypted = @cipher.decrypt(text)
63
+ out["text"] = decrypted
64
+ out[:text] = decrypted
65
+ end
66
+ out
67
+ end
68
+
43
69
  def cosine_similarity(a, b)
44
70
  return 0.0 if a.size != b.size || a.empty?
45
71
  dot = a.zip(b).sum { |x, y| x * y }