llmemory 0.1.13 → 0.1.14
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/lib/llmemory/configuration.rb +35 -1
- data/lib/llmemory/long_term/file_based/memory.rb +19 -0
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +28 -0
- data/lib/llmemory/mcp/tools/memory_add_message.rb +1 -1
- data/lib/llmemory/memory.rb +100 -2
- data/lib/llmemory/retrieval/bm25_scorer.rb +60 -0
- data/lib/llmemory/retrieval/engine.rb +21 -0
- data/lib/llmemory/retrieval/mmr_reranker.rb +56 -0
- data/lib/llmemory/short_term/pruner.rb +65 -0
- data/lib/llmemory/short_term/session_lifecycle.rb +88 -0
- data/lib/llmemory/short_term.rb +1 -0
- data/lib/llmemory/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bdfe8a5f7301af319a99e853c36646491cdc64c581b566811605fb4e63415dd
|
|
4
|
+
data.tar.gz: 27d827d262c35f3e42757416895fe91ad84290032f0091dbfd7a24806da5cf73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 72337abbc9bd02e9289a9b4dad399741f066fc9dbaf01cd2fa8de80813f4042c8939313afb7aa248addf02ade1cdae379a93585618200f96c3e2d0dbafef9138
|
|
7
|
+
data.tar.gz: 263872146fb6654ecfef977b7a7075798b6439e06405eeb512ff48b67c7294387eb963421cbffed1fecf0e4863f7bfc82b0d6f88f3e92eb8cc06898d587c50b2
|
|
@@ -16,7 +16,24 @@ module Llmemory
|
|
|
16
16
|
:time_decay_half_life_days,
|
|
17
17
|
:max_retrieval_tokens,
|
|
18
18
|
:prune_after_days,
|
|
19
|
-
:compact_max_bytes
|
|
19
|
+
:compact_max_bytes,
|
|
20
|
+
:memory_flush_enabled,
|
|
21
|
+
:memory_flush_threshold_tokens,
|
|
22
|
+
:hybrid_search_enabled,
|
|
23
|
+
:bm25_weight,
|
|
24
|
+
:mmr_enabled,
|
|
25
|
+
:mmr_lambda,
|
|
26
|
+
:prune_tool_results_enabled,
|
|
27
|
+
:prune_tool_results_mode,
|
|
28
|
+
:prune_tool_results_max_bytes,
|
|
29
|
+
:context_window_tokens,
|
|
30
|
+
:reserve_tokens,
|
|
31
|
+
:keep_recent_tokens,
|
|
32
|
+
:session_idle_minutes,
|
|
33
|
+
:session_prune_after_days,
|
|
34
|
+
:session_max_entries_per_user,
|
|
35
|
+
:daily_logs_enabled,
|
|
36
|
+
:auto_recall_enabled
|
|
20
37
|
|
|
21
38
|
def initialize
|
|
22
39
|
@llm_provider = :openai
|
|
@@ -34,6 +51,23 @@ module Llmemory
|
|
|
34
51
|
@max_retrieval_tokens = 2000
|
|
35
52
|
@prune_after_days = 90
|
|
36
53
|
@compact_max_bytes = 8192
|
|
54
|
+
@memory_flush_enabled = true
|
|
55
|
+
@memory_flush_threshold_tokens = 4000
|
|
56
|
+
@hybrid_search_enabled = true
|
|
57
|
+
@bm25_weight = 0.3
|
|
58
|
+
@mmr_enabled = false
|
|
59
|
+
@mmr_lambda = 0.7
|
|
60
|
+
@prune_tool_results_enabled = false
|
|
61
|
+
@prune_tool_results_mode = :soft_trim
|
|
62
|
+
@prune_tool_results_max_bytes = 2048
|
|
63
|
+
@context_window_tokens = 128_000
|
|
64
|
+
@reserve_tokens = 16_384
|
|
65
|
+
@keep_recent_tokens = 20_000
|
|
66
|
+
@session_idle_minutes = 60
|
|
67
|
+
@session_prune_after_days = 30
|
|
68
|
+
@session_max_entries_per_user = 500
|
|
69
|
+
@daily_logs_enabled = false
|
|
70
|
+
@auto_recall_enabled = false
|
|
37
71
|
end
|
|
38
72
|
end
|
|
39
73
|
|
|
@@ -18,6 +18,7 @@ module Llmemory
|
|
|
18
18
|
|
|
19
19
|
def memorize(conversation_text)
|
|
20
20
|
resource_id = save_resource(conversation_text)
|
|
21
|
+
append_to_daily_log(conversation_text) if Llmemory.configuration.daily_logs_enabled && @storage.respond_to?(:save_daily_log_entry)
|
|
21
22
|
items = @extractor.extract_items(conversation_text)
|
|
22
23
|
updates_by_category = {}
|
|
23
24
|
|
|
@@ -47,6 +48,7 @@ module Llmemory
|
|
|
47
48
|
uid = user_id || @user_id
|
|
48
49
|
items = @storage.search_items(uid, query)
|
|
49
50
|
resources = @storage.search_resources(uid, query)
|
|
51
|
+
daily_logs = load_daily_logs_for_retrieval(uid) if Llmemory.configuration.daily_logs_enabled && @storage.respond_to?(:load_daily_logs)
|
|
50
52
|
out = []
|
|
51
53
|
items.first(top_k).each do |i|
|
|
52
54
|
out << {
|
|
@@ -62,6 +64,11 @@ module Llmemory
|
|
|
62
64
|
score: 0.9
|
|
63
65
|
}
|
|
64
66
|
end
|
|
67
|
+
if daily_logs
|
|
68
|
+
daily_logs.each do |log|
|
|
69
|
+
out << { text: log[:content], timestamp: log[:date].to_time, score: 0.85 }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
65
72
|
out
|
|
66
73
|
end
|
|
67
74
|
|
|
@@ -77,6 +84,18 @@ module Llmemory
|
|
|
77
84
|
content = item.is_a?(Hash) ? item["content"] || item[:content] : item.to_s
|
|
78
85
|
@storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id)
|
|
79
86
|
end
|
|
87
|
+
|
|
88
|
+
def append_to_daily_log(conversation_text)
|
|
89
|
+
summary = conversation_text.length > 500 ? "#{conversation_text[0..500]}..." : conversation_text
|
|
90
|
+
@storage.save_daily_log_entry(@user_id, Date.today, summary)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load_daily_logs_for_retrieval(user_id)
|
|
94
|
+
today = Date.today
|
|
95
|
+
yesterday = today - 1
|
|
96
|
+
logs = @storage.load_daily_logs(user_id, from_date: yesterday, to_date: today)
|
|
97
|
+
logs.map { |l| { date: l[:date], content: "[#{l[:date]}] #{l[:content]}" } }
|
|
98
|
+
end
|
|
80
99
|
end
|
|
81
100
|
end
|
|
82
101
|
end
|
|
@@ -124,6 +124,29 @@ module Llmemory
|
|
|
124
124
|
resource_ids.each { |id| File.delete(resource_path(user_id, id)) if File.file?(resource_path(user_id, id)) }
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
def save_daily_log_entry(user_id, date, content)
|
|
128
|
+
ensure_user_dir(user_id, "memory")
|
|
129
|
+
path = daily_log_path(user_id, date)
|
|
130
|
+
existing = File.file?(path) ? File.read(path) : ""
|
|
131
|
+
entry = "#{Time.now.strftime('%H:%M')} #{content}\n"
|
|
132
|
+
File.write(path, existing + entry)
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_daily_logs(user_id, from_date:, to_date:)
|
|
137
|
+
from_date = Date.parse(from_date.to_s) if from_date.is_a?(String)
|
|
138
|
+
to_date = Date.parse(to_date.to_s) if to_date.is_a?(String)
|
|
139
|
+
dir = user_path(user_id, "memory")
|
|
140
|
+
return [] unless Dir.exist?(dir)
|
|
141
|
+
|
|
142
|
+
(from_date..to_date).filter_map do |d|
|
|
143
|
+
path = daily_log_path(user_id, d)
|
|
144
|
+
next unless File.file?(path)
|
|
145
|
+
|
|
146
|
+
{ date: d, content: File.read(path) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
127
150
|
def list_users
|
|
128
151
|
return [] unless Dir.exist?(@base_path)
|
|
129
152
|
Dir.children(@base_path).select { |e| File.directory?(File.join(@base_path, e)) && !e.start_with?(".") }
|
|
@@ -162,6 +185,11 @@ module Llmemory
|
|
|
162
185
|
File.join(user_path(user_id, "items"), "#{id}.json")
|
|
163
186
|
end
|
|
164
187
|
|
|
188
|
+
def daily_log_path(user_id, date)
|
|
189
|
+
date_str = date.respond_to?(:strftime) ? date.strftime("%Y-%m-%d") : date.to_s
|
|
190
|
+
File.join(user_path(user_id, "memory"), "#{date_str}.md")
|
|
191
|
+
end
|
|
192
|
+
|
|
165
193
|
def category_path(user_id, category_name)
|
|
166
194
|
safe = category_name.to_s.gsub(%r{[^\w\-.]}, "_")
|
|
167
195
|
File.join(user_path(user_id, "categories"), "#{safe}.md")
|
|
@@ -10,7 +10,7 @@ module Llmemory
|
|
|
10
10
|
properties: {
|
|
11
11
|
user_id: { type: "string", description: "User identifier" },
|
|
12
12
|
session_id: { type: "string", description: "Session identifier (default: 'default')" },
|
|
13
|
-
role: { type: "string", enum: ["user", "assistant", "system"], description: "Message role" },
|
|
13
|
+
role: { type: "string", enum: ["user", "assistant", "system", "tool", "tool_result"], description: "Message role" },
|
|
14
14
|
content: { type: "string", description: "Message content" }
|
|
15
15
|
},
|
|
16
16
|
required: ["user_id", "role", "content"]
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "short_term/checkpoint"
|
|
4
|
+
require_relative "short_term/pruner"
|
|
4
5
|
require_relative "long_term/file_based"
|
|
5
6
|
require_relative "retrieval/engine"
|
|
6
7
|
|
|
@@ -34,11 +35,42 @@ module Llmemory
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def retrieve(query, max_tokens: nil)
|
|
37
|
-
|
|
38
|
+
msgs = pruned_messages
|
|
39
|
+
short_context = format_short_term_context(msgs)
|
|
38
40
|
long_context = @retrieval_engine.retrieve_for_inference(query, user_id: @user_id, max_tokens: max_tokens)
|
|
39
41
|
combine_contexts(short_context, long_context)
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
def recall_for(query: nil, max_tokens: nil)
|
|
45
|
+
return "" unless Llmemory.configuration.auto_recall_enabled
|
|
46
|
+
|
|
47
|
+
effective_query = query || last_user_message
|
|
48
|
+
return "" if effective_query.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
retrieve(effective_query, max_tokens: max_tokens)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def last_user_message
|
|
54
|
+
msgs = messages
|
|
55
|
+
idx = msgs.rindex { |m| (m[:role] || m["role"]).to_s == "user" }
|
|
56
|
+
idx ? (msgs[idx][:content] || msgs[idx]["content"]).to_s : ""
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def prune!(mode: nil)
|
|
60
|
+
return false unless Llmemory.configuration.prune_tool_results_enabled
|
|
61
|
+
|
|
62
|
+
msgs = messages
|
|
63
|
+
return false if msgs.empty?
|
|
64
|
+
|
|
65
|
+
mode ||= Llmemory.configuration.prune_tool_results_mode
|
|
66
|
+
pruner = ShortTerm::Pruner.new(
|
|
67
|
+
soft_trim_max_bytes: Llmemory.configuration.prune_tool_results_max_bytes
|
|
68
|
+
)
|
|
69
|
+
pruned = pruner.prune!(msgs, mode: mode)
|
|
70
|
+
save_state(messages: pruned)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
42
74
|
def consolidate!
|
|
43
75
|
msgs = messages
|
|
44
76
|
return true if msgs.empty?
|
|
@@ -58,6 +90,8 @@ module Llmemory
|
|
|
58
90
|
current_bytes = messages_byte_size(msgs)
|
|
59
91
|
return false if current_bytes <= max
|
|
60
92
|
|
|
93
|
+
flush_memory_before_compaction!(msgs)
|
|
94
|
+
|
|
61
95
|
old_msgs, recent_msgs = split_messages_by_bytes(msgs, max)
|
|
62
96
|
return false if old_msgs.empty?
|
|
63
97
|
|
|
@@ -67,6 +101,48 @@ module Llmemory
|
|
|
67
101
|
true
|
|
68
102
|
end
|
|
69
103
|
|
|
104
|
+
def maybe_flush_memory!
|
|
105
|
+
return false unless Llmemory.configuration.memory_flush_enabled
|
|
106
|
+
msgs = messages
|
|
107
|
+
return false if msgs.empty?
|
|
108
|
+
return false if estimated_tokens(msgs) < Llmemory.configuration.memory_flush_threshold_tokens
|
|
109
|
+
|
|
110
|
+
consolidate!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def context_tokens
|
|
114
|
+
estimated_tokens(messages)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def should_auto_consolidate?
|
|
118
|
+
ctx = context_tokens
|
|
119
|
+
threshold = Llmemory.configuration.context_window_tokens - Llmemory.configuration.reserve_tokens
|
|
120
|
+
ctx >= threshold
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def should_compact?
|
|
124
|
+
ctx = context_tokens
|
|
125
|
+
threshold = Llmemory.configuration.context_window_tokens - Llmemory.configuration.reserve_tokens
|
|
126
|
+
ctx >= threshold
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def check_context_window!
|
|
130
|
+
return false if messages.empty?
|
|
131
|
+
|
|
132
|
+
flushed = false
|
|
133
|
+
if should_auto_consolidate? && Llmemory.configuration.memory_flush_enabled
|
|
134
|
+
consolidate!
|
|
135
|
+
flushed = true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
compacted = false
|
|
139
|
+
if should_compact?
|
|
140
|
+
compacted = compact!
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
flushed || compacted
|
|
144
|
+
end
|
|
145
|
+
|
|
70
146
|
def user_id
|
|
71
147
|
@user_id
|
|
72
148
|
end
|
|
@@ -92,6 +168,18 @@ module Llmemory
|
|
|
92
168
|
@llm ||= Llmemory::LLM.client
|
|
93
169
|
end
|
|
94
170
|
|
|
171
|
+
def flush_memory_before_compaction!(msgs)
|
|
172
|
+
return unless Llmemory.configuration.memory_flush_enabled
|
|
173
|
+
return if msgs.empty?
|
|
174
|
+
return if estimated_tokens(msgs) < Llmemory.configuration.memory_flush_threshold_tokens
|
|
175
|
+
|
|
176
|
+
consolidate!
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def estimated_tokens(msgs)
|
|
180
|
+
(messages_byte_size(msgs) / 4.0).ceil
|
|
181
|
+
end
|
|
182
|
+
|
|
95
183
|
def messages_byte_size(msgs)
|
|
96
184
|
msgs.sum { |m| message_byte_size(m) }
|
|
97
185
|
end
|
|
@@ -138,7 +226,17 @@ module Llmemory
|
|
|
138
226
|
end
|
|
139
227
|
|
|
140
228
|
def save_state(messages:)
|
|
141
|
-
|
|
229
|
+
state = { STATE_KEY_MESSAGES => messages, last_activity_at: Time.now }
|
|
230
|
+
@checkpoint.save_state(state)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def pruned_messages
|
|
234
|
+
return messages unless Llmemory.configuration.prune_tool_results_enabled
|
|
235
|
+
|
|
236
|
+
pruner = ShortTerm::Pruner.new(
|
|
237
|
+
soft_trim_max_bytes: Llmemory.configuration.prune_tool_results_max_bytes
|
|
238
|
+
)
|
|
239
|
+
pruner.prune!(messages, mode: Llmemory.configuration.prune_tool_results_mode)
|
|
142
240
|
end
|
|
143
241
|
|
|
144
242
|
def format_short_term_context(msgs)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Retrieval
|
|
5
|
+
class Bm25Scorer
|
|
6
|
+
K1 = 1.5
|
|
7
|
+
B = 0.75
|
|
8
|
+
|
|
9
|
+
def initialize(k1: K1, b: B)
|
|
10
|
+
@k1 = k1
|
|
11
|
+
@b = b
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def score_candidates(query, candidates)
|
|
15
|
+
return [] if candidates.empty?
|
|
16
|
+
|
|
17
|
+
query_tokens = tokenize(query)
|
|
18
|
+
return candidates.map { |c| c.merge(bm25_score: 0.0, normalized_bm25: 0.0) } if query_tokens.empty?
|
|
19
|
+
|
|
20
|
+
doc_tokens_list = candidates.map { |c| tokenize((c[:text] || c["text"]).to_s) }
|
|
21
|
+
avg_doc_len = doc_tokens_list.map(&:size).sum.to_f / [doc_tokens_list.size, 1].max
|
|
22
|
+
n_docs = candidates.size
|
|
23
|
+
|
|
24
|
+
doc_freq = Hash.new(0)
|
|
25
|
+
doc_tokens_list.each do |tokens|
|
|
26
|
+
tokens.uniq.each { |t| doc_freq[t] += 1 }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
candidates.each_with_index.map do |c, i|
|
|
30
|
+
doc_tokens = doc_tokens_list[i]
|
|
31
|
+
doc_len = doc_tokens.size
|
|
32
|
+
bm25 = 0.0
|
|
33
|
+
|
|
34
|
+
query_tokens.uniq.each do |term|
|
|
35
|
+
tf = doc_tokens.count(term)
|
|
36
|
+
next if tf.zero?
|
|
37
|
+
|
|
38
|
+
n_qi = doc_freq[term]
|
|
39
|
+
idf = Math.log((n_docs - n_qi + 0.5) / (n_qi + 0.5) + 1.0)
|
|
40
|
+
numerator = tf * (@k1 + 1)
|
|
41
|
+
denom = tf + @k1 * (1 - @b + @b * doc_len.to_f / [avg_doc_len, 1].max)
|
|
42
|
+
bm25 += idf * numerator / denom
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
c.merge(bm25_score: bm25)
|
|
46
|
+
end.tap do |scored|
|
|
47
|
+
max_bm25 = scored.map { |s| s[:bm25_score] }.max.to_f
|
|
48
|
+
max_bm25 = 1.0 if max_bm25.zero?
|
|
49
|
+
scored.each { |s| s[:normalized_bm25] = s[:bm25_score] / max_bm25 }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def tokenize(text)
|
|
56
|
+
text.to_s.downcase.scan(/\b[a-z0-9]{2,}\b/)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "temporal_ranker"
|
|
4
4
|
require_relative "context_assembler"
|
|
5
|
+
require_relative "bm25_scorer"
|
|
6
|
+
require_relative "mmr_reranker"
|
|
5
7
|
|
|
6
8
|
module Llmemory
|
|
7
9
|
module Retrieval
|
|
@@ -13,15 +15,19 @@ module Llmemory
|
|
|
13
15
|
@llm = llm || Llmemory::LLM.client
|
|
14
16
|
@ranker = TemporalRanker.new
|
|
15
17
|
@assembler = ContextAssembler.new
|
|
18
|
+
@bm25_scorer = Bm25Scorer.new
|
|
19
|
+
@mmr_reranker = MmrReranker.new(lambda: Llmemory.configuration.mmr_lambda)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def retrieve_for_inference(user_message, user_id: nil, max_tokens: nil)
|
|
19
23
|
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
20
24
|
search_query = generate_query(user_message)
|
|
21
25
|
candidates = fetch_candidates(search_query, user_id)
|
|
26
|
+
candidates = apply_hybrid_scoring(candidates, search_query) if Llmemory.configuration.hybrid_search_enabled
|
|
22
27
|
|
|
23
28
|
relevant = filter_by_relevance(candidates, user_message)
|
|
24
29
|
ranked = @ranker.rank(relevant)
|
|
30
|
+
ranked = @mmr_reranker.rerank(ranked) if Llmemory.configuration.mmr_enabled
|
|
25
31
|
@assembler.assemble(ranked, max_tokens: max_tokens)
|
|
26
32
|
end
|
|
27
33
|
|
|
@@ -58,6 +64,21 @@ module Llmemory
|
|
|
58
64
|
Time.now
|
|
59
65
|
end
|
|
60
66
|
|
|
67
|
+
def apply_hybrid_scoring(candidates, query)
|
|
68
|
+
return candidates if candidates.empty?
|
|
69
|
+
|
|
70
|
+
scored = @bm25_scorer.score_candidates(query, candidates)
|
|
71
|
+
weight = Llmemory.configuration.bm25_weight.to_f
|
|
72
|
+
weight = 0.3 if weight < 0 || weight > 1
|
|
73
|
+
|
|
74
|
+
scored.map do |c|
|
|
75
|
+
vector_score = (c[:score] || c["score"] || 1.0).to_f
|
|
76
|
+
bm25_norm = (c[:normalized_bm25] || 0).to_f
|
|
77
|
+
hybrid = weight * bm25_norm + (1 - weight) * vector_score
|
|
78
|
+
c.merge(score: hybrid)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
61
82
|
def filter_by_relevance(candidates, user_message)
|
|
62
83
|
return candidates if candidates.size <= 5
|
|
63
84
|
user_lower = user_message.to_s.downcase
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Retrieval
|
|
5
|
+
class MmrReranker
|
|
6
|
+
def initialize(lambda: 0.7)
|
|
7
|
+
@lambda = lambda
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def rerank(candidates, score_key: :temporal_score)
|
|
11
|
+
return candidates if candidates.size <= 1
|
|
12
|
+
|
|
13
|
+
selected = []
|
|
14
|
+
remaining = candidates.dup
|
|
15
|
+
|
|
16
|
+
while remaining.any?
|
|
17
|
+
best_idx = nil
|
|
18
|
+
best_mmr = -Float::INFINITY
|
|
19
|
+
|
|
20
|
+
remaining.each_with_index do |cand, i|
|
|
21
|
+
rel = (cand[score_key] || cand[score_key.to_s] || cand[:score] || cand["score"] || 0).to_f
|
|
22
|
+
max_sim = selected.map { |s| similarity(cand, s) }.max || 0
|
|
23
|
+
mmr = @lambda * rel - (1 - @lambda) * max_sim
|
|
24
|
+
|
|
25
|
+
if mmr > best_mmr
|
|
26
|
+
best_mmr = mmr
|
|
27
|
+
best_idx = i
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
break unless best_idx
|
|
32
|
+
|
|
33
|
+
selected << remaining.delete_at(best_idx)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
selected
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def similarity(a, b)
|
|
42
|
+
text_a = tokenize((a[:text] || a["text"]).to_s)
|
|
43
|
+
text_b = tokenize((b[:text] || b["text"]).to_s)
|
|
44
|
+
return 0.0 if text_a.empty? || text_b.empty?
|
|
45
|
+
|
|
46
|
+
intersection = (text_a & text_b).size
|
|
47
|
+
union = (text_a | text_b).size
|
|
48
|
+
union.zero? ? 0.0 : intersection.to_f / union
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tokenize(text)
|
|
52
|
+
text.downcase.scan(/\b[a-z0-9]{2,}\b/).uniq
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ShortTerm
|
|
5
|
+
class Pruner
|
|
6
|
+
DEFAULT_PRUNABLE_ROLES = %i[tool tool_result].freeze
|
|
7
|
+
PLACEHOLDER = "[Tool result pruned]"
|
|
8
|
+
|
|
9
|
+
def initialize(prunable_roles: nil, soft_trim_max_bytes: 2048, soft_trim_head_ratio: 0.4, soft_trim_tail_ratio: 0.2)
|
|
10
|
+
@prunable_roles = prunable_roles || DEFAULT_PRUNABLE_ROLES.map(&:to_s)
|
|
11
|
+
@soft_trim_max_bytes = soft_trim_max_bytes
|
|
12
|
+
@head_ratio = soft_trim_head_ratio
|
|
13
|
+
@tail_ratio = soft_trim_tail_ratio
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def prune!(messages, mode: :soft_trim)
|
|
17
|
+
return messages.dup if messages.empty?
|
|
18
|
+
|
|
19
|
+
messages.map do |msg|
|
|
20
|
+
if prunable?(msg)
|
|
21
|
+
apply_prune(msg, mode)
|
|
22
|
+
else
|
|
23
|
+
msg.dup
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def prunable?(msg)
|
|
31
|
+
role = (msg[:role] || msg["role"]).to_s
|
|
32
|
+
@prunable_roles.include?(role)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def apply_prune(msg, mode)
|
|
36
|
+
content = (msg[:content] || msg["content"]).to_s
|
|
37
|
+
new_content = case mode.to_s.to_sym
|
|
38
|
+
when :hard_clear
|
|
39
|
+
PLACEHOLDER
|
|
40
|
+
when :soft_trim
|
|
41
|
+
soft_trim(content)
|
|
42
|
+
else
|
|
43
|
+
content
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result = msg.dup
|
|
47
|
+
result[:content] = new_content if result.key?(:content)
|
|
48
|
+
result["content"] = new_content if result.key?("content")
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def soft_trim(content)
|
|
53
|
+
return content if content.bytesize <= @soft_trim_max_bytes
|
|
54
|
+
|
|
55
|
+
head_chars = (@soft_trim_max_bytes * @head_ratio).to_i
|
|
56
|
+
tail_chars = (@soft_trim_max_bytes * @tail_ratio).to_i
|
|
57
|
+
|
|
58
|
+
head = content.byteslice(0, head_chars)
|
|
59
|
+
tail = content.bytesize > (head_chars + tail_chars) ? content.byteslice(-tail_chars, tail_chars) : ""
|
|
60
|
+
|
|
61
|
+
"#{head}\n...\n#{tail}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ShortTerm
|
|
5
|
+
class SessionLifecycle
|
|
6
|
+
def initialize(store: nil)
|
|
7
|
+
@store = store || build_store
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def cleanup_idle_sessions!(user_id:, idle_minutes: nil)
|
|
11
|
+
idle_minutes ||= Llmemory.configuration.session_idle_minutes
|
|
12
|
+
cutoff = Time.now - (idle_minutes * 60)
|
|
13
|
+
deleted = 0
|
|
14
|
+
|
|
15
|
+
@store.list_sessions(user_id: user_id).each do |session_id|
|
|
16
|
+
state = @store.load(user_id, session_id)
|
|
17
|
+
next unless state.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
last_activity = state[:last_activity_at] || state["last_activity_at"]
|
|
20
|
+
next if last_activity.nil?
|
|
21
|
+
|
|
22
|
+
last_time = last_activity.is_a?(Time) ? last_activity : Time.parse(last_activity.to_s)
|
|
23
|
+
if last_time < cutoff
|
|
24
|
+
@store.delete(user_id, session_id)
|
|
25
|
+
deleted += 1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
deleted
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cleanup_stale_sessions!(user_id:, prune_after_days: nil)
|
|
33
|
+
prune_after_days ||= Llmemory.configuration.session_prune_after_days
|
|
34
|
+
cutoff = Time.now - (prune_after_days * 86400)
|
|
35
|
+
deleted = 0
|
|
36
|
+
|
|
37
|
+
@store.list_sessions(user_id: user_id).each do |session_id|
|
|
38
|
+
state = @store.load(user_id, session_id)
|
|
39
|
+
next unless state.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
last_activity = state[:last_activity_at] || state["last_activity_at"]
|
|
42
|
+
next if last_activity.nil?
|
|
43
|
+
|
|
44
|
+
last_time = last_activity.is_a?(Time) ? last_activity : Time.parse(last_activity.to_s)
|
|
45
|
+
if last_time < cutoff
|
|
46
|
+
@store.delete(user_id, session_id)
|
|
47
|
+
deleted += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
deleted
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def enforce_max_entries!(user_id:, max_entries: nil)
|
|
55
|
+
max_entries ||= Llmemory.configuration.session_max_entries_per_user
|
|
56
|
+
sessions = @store.list_sessions(user_id: user_id)
|
|
57
|
+
return 0 if sessions.size <= max_entries
|
|
58
|
+
|
|
59
|
+
session_ages = sessions.map do |session_id|
|
|
60
|
+
state = @store.load(user_id, session_id)
|
|
61
|
+
last_activity = state&.dig(:last_activity_at) || state&.dig("last_activity_at")
|
|
62
|
+
last_time = last_activity.is_a?(Time) ? last_activity : (last_activity ? Time.parse(last_activity.to_s) : Time.at(0))
|
|
63
|
+
[session_id, last_time]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
session_ages.sort_by! { |_, t| t }
|
|
67
|
+
to_delete = session_ages.first(session_ages.size - max_entries).map(&:first)
|
|
68
|
+
to_delete.each { |sid| @store.delete(user_id, sid) }
|
|
69
|
+
to_delete.size
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def build_store
|
|
75
|
+
case Llmemory.configuration.short_term_store.to_sym
|
|
76
|
+
when :memory then Stores::MemoryStore.new
|
|
77
|
+
when :redis then Stores::RedisStore.new
|
|
78
|
+
when :postgres then Stores::PostgresStore.new
|
|
79
|
+
when :active_record, :activerecord
|
|
80
|
+
require_relative "stores/active_record_store"
|
|
81
|
+
Stores::ActiveRecordStore.new
|
|
82
|
+
else
|
|
83
|
+
Stores::MemoryStore.new
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/llmemory/short_term.rb
CHANGED
data/lib/llmemory/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llmemory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- llmemory
|
|
@@ -181,11 +181,15 @@ files:
|
|
|
181
181
|
- lib/llmemory/mcp/tools/memory_timeline_context.rb
|
|
182
182
|
- lib/llmemory/memory.rb
|
|
183
183
|
- lib/llmemory/retrieval.rb
|
|
184
|
+
- lib/llmemory/retrieval/bm25_scorer.rb
|
|
184
185
|
- lib/llmemory/retrieval/context_assembler.rb
|
|
185
186
|
- lib/llmemory/retrieval/engine.rb
|
|
187
|
+
- lib/llmemory/retrieval/mmr_reranker.rb
|
|
186
188
|
- lib/llmemory/retrieval/temporal_ranker.rb
|
|
187
189
|
- lib/llmemory/short_term.rb
|
|
188
190
|
- lib/llmemory/short_term/checkpoint.rb
|
|
191
|
+
- lib/llmemory/short_term/pruner.rb
|
|
192
|
+
- lib/llmemory/short_term/session_lifecycle.rb
|
|
189
193
|
- lib/llmemory/short_term/stores/active_record_checkpoint.rb
|
|
190
194
|
- lib/llmemory/short_term/stores/active_record_store.rb
|
|
191
195
|
- lib/llmemory/short_term/stores/base.rb
|