llmemory 0.2.6 → 0.2.7

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -5
  3. data/app/controllers/llmemory/dashboard/application_controller.rb +12 -0
  4. data/app/views/llmemory/dashboard/short_term/show.html.erb +1 -1
  5. data/lib/llmemory/active_record_helpers.rb +21 -0
  6. data/lib/llmemory/cli/commands/mcp.rb +5 -3
  7. data/lib/llmemory/configuration.rb +41 -6
  8. data/lib/llmemory/crypto/field_helpers.rb +5 -0
  9. data/lib/llmemory/extractors/entity_relation_extractor.rb +9 -2
  10. data/lib/llmemory/extractors/fact_extractor.rb +67 -1
  11. data/lib/llmemory/forget_log.rb +7 -3
  12. data/lib/llmemory/llm/anthropic.rb +4 -6
  13. data/lib/llmemory/llm/http_client.rb +55 -0
  14. data/lib/llmemory/llm/openai.rb +5 -7
  15. data/lib/llmemory/llm/tracking_client.rb +22 -12
  16. data/lib/llmemory/llm/usage.rb +4 -0
  17. data/lib/llmemory/llm/usage_ledger.rb +25 -24
  18. data/lib/llmemory/long_term/episodic/memory.rb +5 -2
  19. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +3 -1
  20. data/lib/llmemory/long_term/file_based/memory.rb +9 -4
  21. data/lib/llmemory/long_term/file_based/storage.rb +15 -2
  22. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +3 -1
  23. data/lib/llmemory/long_term/graph_based/memory.rb +39 -5
  24. data/lib/llmemory/long_term/graph_based/storage.rb +15 -2
  25. data/lib/llmemory/long_term/procedural/memory.rb +5 -2
  26. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +3 -1
  27. data/lib/llmemory/maintenance/consolidator.rb +6 -2
  28. data/lib/llmemory/mcp/authentication.rb +5 -3
  29. data/lib/llmemory/mcp/server.rb +21 -6
  30. data/lib/llmemory/mcp/store_helpers.rb +35 -0
  31. data/lib/llmemory/mcp/tools/memory_save.rb +15 -18
  32. data/lib/llmemory/mcp/tools/memory_search.rb +16 -19
  33. data/lib/llmemory/memory.rb +29 -11
  34. data/lib/llmemory/memory_module.rb +6 -2
  35. data/lib/llmemory/provenance.rb +23 -1
  36. data/lib/llmemory/retrieval/feedback_store.rb +11 -5
  37. data/lib/llmemory/retrieval/multi_source.rb +26 -0
  38. data/lib/llmemory/retrieval/temporal_ranker.rb +4 -2
  39. data/lib/llmemory/retrieval.rb +1 -0
  40. data/lib/llmemory/short_term/pruner.rb +16 -4
  41. data/lib/llmemory/short_term/session_lifecycle.rb +18 -9
  42. data/lib/llmemory/short_term/stores/active_record_store.rb +37 -8
  43. data/lib/llmemory/short_term/stores/base.rb +9 -0
  44. data/lib/llmemory/short_term/stores/key_codec.rb +33 -0
  45. data/lib/llmemory/short_term/stores/memory_store.rb +38 -7
  46. data/lib/llmemory/short_term/stores/postgres_store.rb +81 -33
  47. data/lib/llmemory/short_term/stores/redis_store.rb +85 -9
  48. data/lib/llmemory/short_term/stores.rb +17 -2
  49. data/lib/llmemory/vector_store/active_record_store.rb +23 -8
  50. data/lib/llmemory/vector_store/base.rb +4 -0
  51. data/lib/llmemory/vector_store/memory_store.rb +9 -0
  52. data/lib/llmemory/vector_store/openai_embeddings.rb +6 -7
  53. data/lib/llmemory/version.rb +1 -1
  54. data/lib/llmemory/working_memory.rb +10 -7
  55. metadata +6 -15
@@ -34,7 +34,7 @@ module Llmemory
34
34
  type = long_term_type || Llmemory.configuration.long_term_type || :file_based
35
35
  @long_term = long_term || build_long_term(type)
36
36
  @retrieval_engine = retrieval_engine || Retrieval::Engine.new(
37
- @long_term,
37
+ Retrieval::MultiSource.new(primary: @long_term, memory: self),
38
38
  llm: tracked_llm_client,
39
39
  feedback: Retrieval::FeedbackStore.new(store: @short_term_store)
40
40
  )
@@ -46,7 +46,7 @@ module Llmemory
46
46
  @working_memory ||= WorkingMemory.new(
47
47
  user_id: @user_id,
48
48
  session_id: @session_id,
49
- store: build_short_term_store(@cipher)
49
+ store: @short_term_store
50
50
  )
51
51
  end
52
52
 
@@ -56,7 +56,8 @@ module Llmemory
56
56
  @episodic ||= LongTerm::Episodic::Memory.new(
57
57
  user_id: @user_id,
58
58
  storage: LongTerm::Episodic::Storages.build(cipher: @cipher),
59
- cipher: @cipher
59
+ cipher: @cipher,
60
+ forget_log_store: @short_term_store
60
61
  )
61
62
  end
62
63
 
@@ -65,7 +66,8 @@ module Llmemory
65
66
  @procedural ||= LongTerm::Procedural::Memory.new(
66
67
  user_id: @user_id,
67
68
  storage: LongTerm::Procedural::Storages.build(cipher: @cipher),
68
- cipher: @cipher
69
+ cipher: @cipher,
70
+ forget_log_store: @short_term_store
69
71
  )
70
72
  end
71
73
 
@@ -103,9 +105,14 @@ module Llmemory
103
105
  end
104
106
 
105
107
  def add_message(role:, content:)
106
- msgs = messages
107
- msgs << { role: role.to_sym, content: content.to_s }
108
- save_state(messages: msgs, **preserved_flush_state)
108
+ @short_term_store.update(@user_id, @session_id) do |state|
109
+ state = normalize_state_hash(state)
110
+ list = state[STATE_KEY_MESSAGES]
111
+ list = list.is_a?(Array) ? list.dup : []
112
+ list << { role: role.to_sym, content: content.to_s }
113
+ list = sanitize_messages(list) if Llmemory.configuration.message_sanitizer_enabled
114
+ state.merge(STATE_KEY_MESSAGES => list, last_activity_at: Time.now, **preserved_flush_state_from(state))
115
+ end
109
116
  true
110
117
  end
111
118
 
@@ -164,6 +171,7 @@ module Llmemory
164
171
 
165
172
  def clear_session!
166
173
  @checkpoint.clear_state
174
+ working_memory.clear!
167
175
  true
168
176
  end
169
177
 
@@ -315,13 +323,23 @@ module Llmemory
315
323
  end
316
324
 
317
325
  def preserved_flush_state
318
- state = restore_state_for_save
326
+ preserved_flush_state_from(restore_state_for_save)
327
+ end
328
+
329
+ def preserved_flush_state_from(state)
330
+ state = normalize_state_hash(state)
319
331
  {}.tap do |h|
320
- h[:last_flush_at] = state[:last_flush_at] || state["last_flush_at"] if state[:last_flush_at] || state["last_flush_at"]
321
- h[:last_compact_at] = state[:last_compact_at] || state["last_compact_at"] if state[:last_compact_at] || state["last_compact_at"]
332
+ h[:last_flush_at] = state[:last_flush_at] if state[:last_flush_at]
333
+ h[:last_compact_at] = state[:last_compact_at] if state[:last_compact_at]
322
334
  end
323
335
  end
324
336
 
337
+ def normalize_state_hash(state)
338
+ return {} unless state.is_a?(Hash)
339
+
340
+ state.transform_keys(&:to_sym)
341
+ end
342
+
325
343
  def estimated_tokens(msgs)
326
344
  (messages_byte_size(msgs) / 4.0).ceil
327
345
  end
@@ -358,7 +376,7 @@ module Llmemory
358
376
  end
359
377
 
360
378
  def build_long_term(long_term_type)
361
- llm_opts = { llm: tracked_llm_client }
379
+ llm_opts = { llm: tracked_llm_client, forget_log_store: @short_term_store }
362
380
  case long_term_type.to_s.to_sym
363
381
  when :graph_based
364
382
  LongTerm::GraphBased::Memory.new(
@@ -54,9 +54,13 @@ module Llmemory
54
54
  end
55
55
 
56
56
  # Shared audit trail for #forget. Lazily built; override or inject by setting
57
- # @forget_log if a specific backend/store is required.
57
+ # @forget_log, or pass @forget_log_store when constructing the memory.
58
58
  def forget_log
59
- @forget_log ||= Llmemory::ForgetLog.new
59
+ @forget_log ||= Llmemory::ForgetLog.new(store: forget_log_store)
60
+ end
61
+
62
+ def forget_log_store
63
+ @forget_log_store
60
64
  end
61
65
  end
62
66
  end
@@ -20,7 +20,7 @@ module Llmemory
20
20
  {
21
21
  sources: Array(sources).filter_map { |s| normalize_source(s) },
22
22
  method: method&.to_s,
23
- confidence: confidence.nil? ? nil : confidence.to_f,
23
+ confidence: normalize_confidence(confidence),
24
24
  created_at: normalize_time(created_at)
25
25
  }
26
26
  end
@@ -60,5 +60,27 @@ module Llmemory
60
60
  value ||= Time.now
61
61
  value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
62
62
  end
63
+
64
+ CONFIDENCE_LABELS = {
65
+ "high" => 0.9,
66
+ "medium" => 0.6,
67
+ "low" => 0.3
68
+ }.freeze
69
+
70
+ def normalize_confidence(value)
71
+ return nil if value.nil?
72
+
73
+ if value.is_a?(Numeric)
74
+ return [[value.to_f, 0.0].max, 1.0].min
75
+ end
76
+
77
+ label = value.to_s.downcase.strip
78
+ return CONFIDENCE_LABELS[label] if CONFIDENCE_LABELS.key?(label)
79
+
80
+ numeric = label.to_f
81
+ return nil if numeric.zero? && !label.match?(/\A0(\.0+)?\z/)
82
+
83
+ [[numeric, 0.0].max, 1.0].min
84
+ end
63
85
  end
64
86
  end
@@ -22,11 +22,14 @@ module Llmemory
22
22
 
23
23
  def record(user_id, item_id, delta)
24
24
  return if user_id.nil? || item_id.nil?
25
- state = load(user_id)
25
+
26
26
  key = item_id.to_s
27
- state[key] = (state[key] || 0) + delta.to_i
28
- @store.save(user_id, SESSION_KEY, state)
29
- state[key]
27
+ state = @store.update(user_id, SESSION_KEY) do |current|
28
+ current = load_from_state(current)
29
+ current[key] = (current[key] || 0) + delta.to_i
30
+ current
31
+ end
32
+ state ? state[key] : nil
30
33
  end
31
34
 
32
35
  def net(user_id, item_id)
@@ -41,7 +44,10 @@ module Llmemory
41
44
  private
42
45
 
43
46
  def load(user_id)
44
- state = @store.load(user_id, SESSION_KEY)
47
+ load_from_state(@store.load(user_id, SESSION_KEY))
48
+ end
49
+
50
+ def load_from_state(state)
45
51
  return {} unless state.is_a?(Hash)
46
52
  state.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_i }
47
53
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module Retrieval
5
+ # Combines semantic, episodic and procedural memory for unified retrieval.
6
+ class MultiSource
7
+ def initialize(primary:, memory:)
8
+ @primary = primary
9
+ @memory = memory
10
+ end
11
+
12
+ def user_id
13
+ @memory.user_id
14
+ end
15
+
16
+ def search_candidates(query, user_id: nil, top_k: 20)
17
+ uid = user_id || @memory.user_id
18
+ combined = []
19
+ combined.concat(@primary.search_candidates(query, user_id: uid, top_k: top_k))
20
+ combined.concat(@memory.episodic.search_candidates(query, user_id: uid, top_k: top_k))
21
+ combined.concat(@memory.procedural.search_candidates(query, user_id: uid, top_k: top_k))
22
+ combined
23
+ end
24
+ end
25
+ end
26
+ end
@@ -9,14 +9,16 @@ module Llmemory
9
9
  end
10
10
 
11
11
  def rank(candidates, now: Time.now)
12
- lambda_val = Math.log(2) / @half_life_days.to_f
12
+ half_life = @half_life_days.to_f
13
+ half_life = 30.0 if half_life <= 0
14
+ lambda_val = Math.log(2) / half_life
13
15
  weight = [@importance_weight.to_f, 0.0].max
14
16
 
15
17
  candidates.map do |c|
16
18
  score = (c[:score] || c["score"] || 1.0).to_f
17
19
  timestamp = c[:timestamp] || c["timestamp"]
18
20
  timestamp = Time.parse(timestamp.to_s) if timestamp.is_a?(String)
19
- age_days = timestamp ? (now - timestamp).to_i / 86400 : 0
21
+ age_days = timestamp ? [(now - timestamp) / 86_400.0, 0.0].max : 0.0
20
22
 
21
23
  time_decay = if c[:evergreen] || c["evergreen"]
22
24
  1.0
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "retrieval/temporal_ranker"
4
4
  require_relative "retrieval/context_assembler"
5
+ require_relative "retrieval/multi_source"
5
6
  require_relative "retrieval/engine"
6
7
 
7
8
  module Llmemory
@@ -52,14 +52,26 @@ module Llmemory
52
52
  def soft_trim(content)
53
53
  return content if content.bytesize <= @soft_trim_max_bytes
54
54
 
55
- head_chars = (@soft_trim_max_bytes * @head_ratio).to_i
56
- tail_chars = (@soft_trim_max_bytes * @tail_ratio).to_i
55
+ head_bytes = (@soft_trim_max_bytes * @head_ratio).to_i
56
+ tail_bytes = (@soft_trim_max_bytes * @tail_ratio).to_i
57
57
 
58
- head = content.byteslice(0, head_chars)
59
- tail = content.bytesize > (head_chars + tail_chars) ? content.byteslice(-tail_chars, tail_chars) : ""
58
+ head = utf8_safe_byteslice(content, 0, head_bytes)
59
+ tail = if content.bytesize > (head_bytes + tail_bytes)
60
+ utf8_safe_byteslice(content, -tail_bytes, tail_bytes)
61
+ else
62
+ ""
63
+ end
60
64
 
61
65
  "#{head}\n...\n#{tail}"
62
66
  end
67
+
68
+ def utf8_safe_byteslice(str, start, length = nil)
69
+ slice = length.nil? ? str.byteslice(start) : str.byteslice(start, length)
70
+ return "" if slice.nil?
71
+
72
+ slice.force_encoding(str.encoding)
73
+ slice.scrub
74
+ end
63
75
  end
64
76
  end
65
77
  end
@@ -30,10 +30,9 @@ module Llmemory
30
30
  state = @store.load(user_id, session_id)
31
31
  next unless state.is_a?(Hash)
32
32
 
33
- last_activity = state[:last_activity_at] || state["last_activity_at"]
34
- next if last_activity.nil?
33
+ last_time = parse_activity_time(state)
34
+ next if last_time.nil?
35
35
 
36
- last_time = last_activity.is_a?(Time) ? last_activity : Time.parse(last_activity.to_s)
37
36
  if last_time < cutoff
38
37
  @store.delete(user_id, session_id)
39
38
  deleted += 1
@@ -52,10 +51,9 @@ module Llmemory
52
51
  state = @store.load(user_id, session_id)
53
52
  next unless state.is_a?(Hash)
54
53
 
55
- last_activity = state[:last_activity_at] || state["last_activity_at"]
56
- next if last_activity.nil?
54
+ last_time = parse_activity_time(state)
55
+ next if last_time.nil?
57
56
 
58
- last_time = last_activity.is_a?(Time) ? last_activity : Time.parse(last_activity.to_s)
59
57
  if last_time < cutoff
60
58
  @store.delete(user_id, session_id)
61
59
  deleted += 1
@@ -70,10 +68,9 @@ module Llmemory
70
68
  sessions = user_sessions(user_id)
71
69
  return 0 if sessions.size <= max_entries
72
70
 
73
- session_ages = sessions.map do |session_id|
71
+ session_ages = sessions.filter_map do |session_id|
74
72
  state = @store.load(user_id, session_id)
75
- last_activity = state&.dig(:last_activity_at) || state&.dig("last_activity_at")
76
- last_time = last_activity.is_a?(Time) ? last_activity : (last_activity ? Time.parse(last_activity.to_s) : Time.at(0))
73
+ last_time = parse_activity_time(state) || Time.at(0)
77
74
  [session_id, last_time]
78
75
  end
79
76
 
@@ -89,6 +86,18 @@ module Llmemory
89
86
  @store.list_sessions(user_id: user_id).reject { |s| self.class.pseudo_session?(s) }
90
87
  end
91
88
 
89
+ def parse_activity_time(state)
90
+ return nil unless state.is_a?(Hash)
91
+
92
+ last_activity = state[:last_activity_at] || state["last_activity_at"]
93
+ return nil if last_activity.nil?
94
+ return last_activity if last_activity.is_a?(Time)
95
+
96
+ Time.parse(last_activity.to_s)
97
+ rescue ArgumentError, TypeError
98
+ nil
99
+ end
100
+
92
101
  def build_store
93
102
  Stores.build
94
103
  end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../../crypto/field_helpers"
4
+ require_relative "../../active_record_helpers"
5
5
 
6
6
  module Llmemory
7
7
  module ShortTerm
8
8
  module Stores
9
9
  class ActiveRecordStore < Base
10
10
  include Llmemory::Crypto::FieldHelpers
11
+ include Llmemory::ActiveRecordHelpers
11
12
 
12
13
  def initialize(cipher: nil)
13
14
  @cipher = cipher || Llmemory.build_cipher
@@ -22,13 +23,15 @@ module Llmemory
22
23
  end
23
24
 
24
25
  def save(user_id, session_id, state)
25
- record = Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.find_or_initialize_by(
26
- user_id: user_id,
27
- session_id: session_id
28
- )
29
- record.state = cipher.enabled? ? serialize_state(state) : state
30
- record.updated_at = Time.current
31
- record.save!
26
+ with_unique_retry do
27
+ record = Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.find_or_initialize_by(
28
+ user_id: user_id,
29
+ session_id: session_id
30
+ )
31
+ record.state = cipher.enabled? ? serialize_state(state) : state
32
+ record.updated_at = Time.current
33
+ record.save!
34
+ end
32
35
  true
33
36
  end
34
37
 
@@ -55,6 +58,32 @@ module Llmemory
55
58
  true
56
59
  end
57
60
 
61
+ def update(user_id, session_id, &block)
62
+ result = nil
63
+ Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.transaction do
64
+ record = Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.lock.find_by(
65
+ user_id: user_id,
66
+ session_id: session_id
67
+ )
68
+ current = if record
69
+ raw = record.state
70
+ raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : deserialize_state(raw)
71
+ end
72
+ new_state = yield(current)
73
+ next if new_state.nil?
74
+
75
+ record ||= Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.new(
76
+ user_id: user_id,
77
+ session_id: session_id
78
+ )
79
+ record.state = cipher.enabled? ? serialize_state(new_state) : new_state
80
+ record.updated_at = Time.current
81
+ with_unique_retry { record.save! }
82
+ result = new_state
83
+ end
84
+ result
85
+ end
86
+
58
87
  def list_users
59
88
  Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.distinct.pluck(:user_id)
60
89
  end
@@ -16,6 +16,15 @@ module Llmemory
16
16
  raise NotImplementedError, "#{self.class}#delete must be implemented"
17
17
  end
18
18
 
19
+ # Atomic read-modify-write. Yields the current state (may be nil) and
20
+ # persists the block's return value when non-nil.
21
+ def update(user_id, session_id)
22
+ current = load(user_id, session_id)
23
+ new_state = yield(current)
24
+ save(user_id, session_id, new_state) unless new_state.nil?
25
+ new_state
26
+ end
27
+
19
28
  def list_users
20
29
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
21
30
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ShortTerm
5
+ module Stores
6
+ # Escapes user/session identifiers so composite keys remain unambiguous
7
+ # when ids contain the separator character `:`.
8
+ module KeyCodec
9
+ ESCAPE = "%3A"
10
+ SEPARATOR = ":"
11
+
12
+ module_function
13
+
14
+ def encode(value)
15
+ value.to_s.gsub("%", "%25").gsub(SEPARATOR, ESCAPE)
16
+ end
17
+
18
+ def decode(value)
19
+ value.to_s.gsub(ESCAPE, SEPARATOR).gsub("%25", "%")
20
+ end
21
+
22
+ def composite_key(*parts)
23
+ parts.map { |part| encode(part) }.join(SEPARATOR)
24
+ end
25
+
26
+ def split_composite_key(key, parts: 2)
27
+ encoded = key.to_s.split(SEPARATOR, parts)
28
+ encoded.map { |part| decode(part) }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "base"
5
+ require_relative "key_codec"
4
6
 
5
7
  module Llmemory
6
8
  module ShortTerm
@@ -8,37 +10,66 @@ module Llmemory
8
10
  class MemoryStore < Base
9
11
  def initialize(cipher: nil)
10
12
  @store = {}
13
+ @mutexes = Hash.new { |h, k| h[k] = Mutex.new }
11
14
  end
12
15
 
13
16
  def save(user_id, session_id, state)
14
17
  key = key_for(user_id, session_id)
15
- @store[key] = { state: state, updated_at: Time.now }
18
+ @mutexes[key].synchronize do
19
+ @store[key] = { state: deep_copy(state), updated_at: Time.now }
20
+ end
16
21
  true
17
22
  end
18
23
 
19
24
  def load(user_id, session_id)
20
25
  key = key_for(user_id, session_id)
21
- @store.dig(key, :state)
26
+ @mutexes[key].synchronize do
27
+ deep_copy(@store.dig(key, :state))
28
+ end
22
29
  end
23
30
 
24
31
  def delete(user_id, session_id)
25
- @store.delete(key_for(user_id, session_id))
32
+ key = key_for(user_id, session_id)
33
+ @mutexes[key].synchronize do
34
+ @store.delete(key)
35
+ end
26
36
  true
27
37
  end
28
38
 
39
+ def update(user_id, session_id, &block)
40
+ key = key_for(user_id, session_id)
41
+ @mutexes[key].synchronize do
42
+ current = deep_copy(@store.dig(key, :state))
43
+ new_state = yield(current)
44
+ unless new_state.nil?
45
+ @store[key] = { state: deep_copy(new_state), updated_at: Time.now }
46
+ end
47
+ deep_copy(new_state)
48
+ end
49
+ end
50
+
29
51
  def list_users
30
- @store.keys.map { |k| k.split(":", 2).first }.uniq
52
+ @store.keys.map { |k| KeyCodec.split_composite_key(k, parts: 2).first }.uniq
31
53
  end
32
54
 
33
55
  def list_sessions(user_id:)
34
- prefix = "#{user_id}:"
35
- @store.keys.select { |k| k.start_with?(prefix) }.map { |k| k.split(":", 2).last }
56
+ encoded_user = KeyCodec.encode(user_id)
57
+ prefix = "#{encoded_user}#{KeyCodec::SEPARATOR}"
58
+ @store.keys
59
+ .select { |k| k.start_with?(prefix) }
60
+ .map { |k| KeyCodec.split_composite_key(k, parts: 2).last }
36
61
  end
37
62
 
38
63
  private
39
64
 
40
65
  def key_for(user_id, session_id)
41
- "#{user_id}:#{session_id}"
66
+ KeyCodec.composite_key(user_id, session_id)
67
+ end
68
+
69
+ def deep_copy(value)
70
+ return nil if value.nil?
71
+
72
+ Marshal.load(Marshal.dump(value))
42
73
  end
43
74
  end
44
75
  end