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.
- checksums.yaml +4 -4
- data/README.md +22 -5
- data/app/controllers/llmemory/dashboard/application_controller.rb +12 -0
- data/app/views/llmemory/dashboard/short_term/show.html.erb +1 -1
- data/lib/llmemory/active_record_helpers.rb +21 -0
- data/lib/llmemory/cli/commands/mcp.rb +5 -3
- data/lib/llmemory/configuration.rb +41 -6
- data/lib/llmemory/crypto/field_helpers.rb +5 -0
- data/lib/llmemory/extractors/entity_relation_extractor.rb +9 -2
- data/lib/llmemory/extractors/fact_extractor.rb +67 -1
- data/lib/llmemory/forget_log.rb +7 -3
- data/lib/llmemory/llm/anthropic.rb +4 -6
- data/lib/llmemory/llm/http_client.rb +55 -0
- data/lib/llmemory/llm/openai.rb +5 -7
- data/lib/llmemory/llm/tracking_client.rb +22 -12
- data/lib/llmemory/llm/usage.rb +4 -0
- data/lib/llmemory/llm/usage_ledger.rb +25 -24
- data/lib/llmemory/long_term/episodic/memory.rb +5 -2
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/long_term/file_based/memory.rb +9 -4
- data/lib/llmemory/long_term/file_based/storage.rb +15 -2
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/long_term/graph_based/memory.rb +39 -5
- data/lib/llmemory/long_term/graph_based/storage.rb +15 -2
- data/lib/llmemory/long_term/procedural/memory.rb +5 -2
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/maintenance/consolidator.rb +6 -2
- data/lib/llmemory/mcp/authentication.rb +5 -3
- data/lib/llmemory/mcp/server.rb +21 -6
- data/lib/llmemory/mcp/store_helpers.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_save.rb +15 -18
- data/lib/llmemory/mcp/tools/memory_search.rb +16 -19
- data/lib/llmemory/memory.rb +29 -11
- data/lib/llmemory/memory_module.rb +6 -2
- data/lib/llmemory/provenance.rb +23 -1
- data/lib/llmemory/retrieval/feedback_store.rb +11 -5
- data/lib/llmemory/retrieval/multi_source.rb +26 -0
- data/lib/llmemory/retrieval/temporal_ranker.rb +4 -2
- data/lib/llmemory/retrieval.rb +1 -0
- data/lib/llmemory/short_term/pruner.rb +16 -4
- data/lib/llmemory/short_term/session_lifecycle.rb +18 -9
- data/lib/llmemory/short_term/stores/active_record_store.rb +37 -8
- data/lib/llmemory/short_term/stores/base.rb +9 -0
- data/lib/llmemory/short_term/stores/key_codec.rb +33 -0
- data/lib/llmemory/short_term/stores/memory_store.rb +38 -7
- data/lib/llmemory/short_term/stores/postgres_store.rb +81 -33
- data/lib/llmemory/short_term/stores/redis_store.rb +85 -9
- data/lib/llmemory/short_term/stores.rb +17 -2
- data/lib/llmemory/vector_store/active_record_store.rb +23 -8
- data/lib/llmemory/vector_store/base.rb +4 -0
- data/lib/llmemory/vector_store/memory_store.rb +9 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +6 -7
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +10 -7
- metadata +6 -15
data/lib/llmemory/memory.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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]
|
|
321
|
-
h[: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
|
|
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
|
data/lib/llmemory/provenance.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
+
|
|
26
26
|
key = item_id.to_s
|
|
27
|
-
state
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
data/lib/llmemory/retrieval.rb
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
|
59
|
-
tail = content.bytesize > (
|
|
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
|
-
|
|
34
|
-
next if
|
|
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
|
-
|
|
56
|
-
next if
|
|
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.
|
|
71
|
+
session_ages = sessions.filter_map do |session_id|
|
|
74
72
|
state = @store.load(user_id, session_id)
|
|
75
|
-
|
|
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 "../../
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|