llmemory 0.1.13 → 0.1.15
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 +104 -3
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +1 -0
- data/lib/llmemory/configuration.rb +51 -1
- data/lib/llmemory/extractors/fact_extractor.rb +9 -2
- data/lib/llmemory/long_term/file_based/memory.rb +53 -6
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +12 -6
- data/lib/llmemory/long_term/file_based/storages/base.rb +1 -1
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +11 -8
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +30 -1
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -1
- data/lib/llmemory/long_term/graph_based/memory.rb +5 -1
- data/lib/llmemory/mcp/tools/memory_add_message.rb +1 -1
- data/lib/llmemory/memory.rb +157 -6
- data/lib/llmemory/noise_filter.rb +36 -0
- data/lib/llmemory/retrieval/bm25_scorer.rb +60 -0
- data/lib/llmemory/retrieval/engine.rb +23 -1
- data/lib/llmemory/retrieval/mmr_reranker.rb +56 -0
- data/lib/llmemory/retrieval/temporal_ranker.rb +9 -1
- data/lib/llmemory/short_term/message_sanitizer.rb +43 -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 +2 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +45 -10
- data/lib/llmemory/version.rb +1 -1
- data/lib/tasks/release.rake +100 -0
- metadata +22 -1
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
|
|
|
@@ -22,7 +23,7 @@ module Llmemory
|
|
|
22
23
|
def add_message(role:, content:)
|
|
23
24
|
msgs = messages
|
|
24
25
|
msgs << { role: role.to_sym, content: content.to_s }
|
|
25
|
-
save_state(messages: msgs)
|
|
26
|
+
save_state(messages: msgs, **preserved_flush_state)
|
|
26
27
|
true
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -30,15 +31,47 @@ module Llmemory
|
|
|
30
31
|
state = @checkpoint.restore_state
|
|
31
32
|
return [] unless state.is_a?(Hash)
|
|
32
33
|
list = state[STATE_KEY_MESSAGES] || state[STATE_KEY_MESSAGES.to_s]
|
|
33
|
-
list.is_a?(Array) ? list.dup : []
|
|
34
|
+
list = list.is_a?(Array) ? list.dup : []
|
|
35
|
+
sanitize_messages(list)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def retrieve(query, max_tokens: nil)
|
|
37
|
-
|
|
39
|
+
msgs = pruned_messages
|
|
40
|
+
short_context = format_short_term_context(msgs)
|
|
38
41
|
long_context = @retrieval_engine.retrieve_for_inference(query, user_id: @user_id, max_tokens: max_tokens)
|
|
39
42
|
combine_contexts(short_context, long_context)
|
|
40
43
|
end
|
|
41
44
|
|
|
45
|
+
def recall_for(query: nil, max_tokens: nil)
|
|
46
|
+
return "" unless Llmemory.configuration.auto_recall_enabled
|
|
47
|
+
|
|
48
|
+
effective_query = query || last_user_message
|
|
49
|
+
return "" if effective_query.to_s.strip.empty?
|
|
50
|
+
|
|
51
|
+
retrieve(effective_query, max_tokens: max_tokens)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def last_user_message
|
|
55
|
+
msgs = messages
|
|
56
|
+
idx = msgs.rindex { |m| (m[:role] || m["role"]).to_s == "user" }
|
|
57
|
+
idx ? (msgs[idx][:content] || msgs[idx]["content"]).to_s : ""
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prune!(mode: nil)
|
|
61
|
+
return false unless Llmemory.configuration.prune_tool_results_enabled
|
|
62
|
+
|
|
63
|
+
msgs = messages
|
|
64
|
+
return false if msgs.empty?
|
|
65
|
+
|
|
66
|
+
mode ||= Llmemory.configuration.prune_tool_results_mode
|
|
67
|
+
pruner = ShortTerm::Pruner.new(
|
|
68
|
+
soft_trim_max_bytes: Llmemory.configuration.prune_tool_results_max_bytes
|
|
69
|
+
)
|
|
70
|
+
pruned = pruner.prune!(msgs, mode: mode)
|
|
71
|
+
save_state(messages: pruned, **preserved_flush_state)
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
42
75
|
def consolidate!
|
|
43
76
|
msgs = messages
|
|
44
77
|
return true if msgs.empty?
|
|
@@ -58,15 +91,80 @@ module Llmemory
|
|
|
58
91
|
current_bytes = messages_byte_size(msgs)
|
|
59
92
|
return false if current_bytes <= max
|
|
60
93
|
|
|
94
|
+
flushed = flush_memory_before_compaction!(msgs)
|
|
95
|
+
|
|
61
96
|
old_msgs, recent_msgs = split_messages_by_bytes(msgs, max)
|
|
62
97
|
return false if old_msgs.empty?
|
|
63
98
|
|
|
64
99
|
summary = summarize_messages(old_msgs)
|
|
65
100
|
compacted = [{ role: :system, content: summary }] + recent_msgs
|
|
66
|
-
|
|
101
|
+
state = restore_state_for_save
|
|
102
|
+
flush_ts = flushed ? Time.now : (state[:last_flush_at] || state["last_flush_at"])
|
|
103
|
+
save_state(messages: compacted, last_compact_at: Time.now, last_flush_at: flush_ts)
|
|
67
104
|
true
|
|
68
105
|
end
|
|
69
106
|
|
|
107
|
+
def maybe_flush_memory!
|
|
108
|
+
return false unless Llmemory.configuration.memory_flush_enabled
|
|
109
|
+
msgs = messages
|
|
110
|
+
return false if msgs.empty?
|
|
111
|
+
return false if estimated_tokens(msgs) < Llmemory.configuration.memory_flush_threshold_tokens
|
|
112
|
+
|
|
113
|
+
consolidate!
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def context_tokens
|
|
117
|
+
estimated_tokens(messages)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def should_auto_consolidate?
|
|
121
|
+
ctx = context_tokens
|
|
122
|
+
threshold = Llmemory.configuration.context_window_tokens - Llmemory.configuration.reserve_tokens
|
|
123
|
+
ctx >= threshold
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def should_compact?
|
|
127
|
+
ctx = context_tokens
|
|
128
|
+
threshold = Llmemory.configuration.context_window_tokens - Llmemory.configuration.reserve_tokens
|
|
129
|
+
ctx >= threshold
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def with_overflow_recovery(max_retries: 2, &block)
|
|
133
|
+
return yield unless Llmemory.configuration.overflow_recovery_enabled
|
|
134
|
+
return yield unless block_given?
|
|
135
|
+
|
|
136
|
+
retries = 0
|
|
137
|
+
begin
|
|
138
|
+
yield
|
|
139
|
+
rescue Llmemory::LLMError => e
|
|
140
|
+
msg = e.message.to_s.downcase
|
|
141
|
+
overflow = msg.include?("context") || msg.include?("token") || msg.include?("overflow") || msg.include?("limit")
|
|
142
|
+
raise unless overflow && retries < max_retries
|
|
143
|
+
|
|
144
|
+
prune! if Llmemory.configuration.prune_tool_results_enabled
|
|
145
|
+
compact!
|
|
146
|
+
retries += 1
|
|
147
|
+
retry
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_context_window!
|
|
152
|
+
return false if messages.empty?
|
|
153
|
+
|
|
154
|
+
flushed = false
|
|
155
|
+
if should_auto_consolidate? && Llmemory.configuration.memory_flush_enabled
|
|
156
|
+
consolidate!
|
|
157
|
+
flushed = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
compacted = false
|
|
161
|
+
if should_compact?
|
|
162
|
+
compacted = compact!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
flushed || compacted
|
|
166
|
+
end
|
|
167
|
+
|
|
70
168
|
def user_id
|
|
71
169
|
@user_id
|
|
72
170
|
end
|
|
@@ -92,6 +190,47 @@ module Llmemory
|
|
|
92
190
|
@llm ||= Llmemory::LLM.client
|
|
93
191
|
end
|
|
94
192
|
|
|
193
|
+
def flush_memory_before_compaction!(msgs)
|
|
194
|
+
return false unless Llmemory.configuration.memory_flush_enabled
|
|
195
|
+
return false if msgs.empty?
|
|
196
|
+
return false if estimated_tokens(msgs) < Llmemory.configuration.memory_flush_threshold_tokens
|
|
197
|
+
|
|
198
|
+
state = restore_state_for_save
|
|
199
|
+
last_compact = state[:last_compact_at] || state["last_compact_at"]
|
|
200
|
+
window = Llmemory.configuration.flush_once_per_cycle_seconds.to_i
|
|
201
|
+
|
|
202
|
+
if last_compact
|
|
203
|
+
t = last_compact.is_a?(Time) ? last_compact : Time.parse(last_compact.to_s)
|
|
204
|
+
return false if (Time.now - t).to_i < window
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
consolidate!
|
|
208
|
+
true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def sanitize_messages(msgs)
|
|
212
|
+
return msgs unless Llmemory.configuration.message_sanitizer_enabled
|
|
213
|
+
|
|
214
|
+
sanitizer = ShortTerm::MessageSanitizer.new
|
|
215
|
+
sanitizer.sanitize!(msgs)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def restore_state_for_save
|
|
219
|
+
@checkpoint.restore_state || {}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def preserved_flush_state
|
|
223
|
+
state = restore_state_for_save
|
|
224
|
+
{}.tap do |h|
|
|
225
|
+
h[:last_flush_at] = state[:last_flush_at] || state["last_flush_at"] if state[:last_flush_at] || state["last_flush_at"]
|
|
226
|
+
h[:last_compact_at] = state[:last_compact_at] || state["last_compact_at"] if state[:last_compact_at] || state["last_compact_at"]
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def estimated_tokens(msgs)
|
|
231
|
+
(messages_byte_size(msgs) / 4.0).ceil
|
|
232
|
+
end
|
|
233
|
+
|
|
95
234
|
def messages_byte_size(msgs)
|
|
96
235
|
msgs.sum { |m| message_byte_size(m) }
|
|
97
236
|
end
|
|
@@ -137,8 +276,20 @@ module Llmemory
|
|
|
137
276
|
end
|
|
138
277
|
end
|
|
139
278
|
|
|
140
|
-
def save_state(messages:)
|
|
141
|
-
|
|
279
|
+
def save_state(messages:, last_flush_at: nil, last_compact_at: nil)
|
|
280
|
+
state = { STATE_KEY_MESSAGES => messages, last_activity_at: Time.now }
|
|
281
|
+
state[:last_flush_at] = last_flush_at if last_flush_at
|
|
282
|
+
state[:last_compact_at] = last_compact_at if last_compact_at
|
|
283
|
+
@checkpoint.save_state(state)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def pruned_messages
|
|
287
|
+
return messages unless Llmemory.configuration.prune_tool_results_enabled
|
|
288
|
+
|
|
289
|
+
pruner = ShortTerm::Pruner.new(
|
|
290
|
+
soft_trim_max_bytes: Llmemory.configuration.prune_tool_results_max_bytes
|
|
291
|
+
)
|
|
292
|
+
pruner.prune!(messages, mode: Llmemory.configuration.prune_tool_results_mode)
|
|
142
293
|
end
|
|
143
294
|
|
|
144
295
|
def format_short_term_context(msgs)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
class NoiseFilter
|
|
5
|
+
NO_REPLY_MARKER = "NO_REPLY"
|
|
6
|
+
DEFAULT_MIN_CHARS = 10
|
|
7
|
+
|
|
8
|
+
def initialize(min_chars: nil, enabled: true)
|
|
9
|
+
@min_chars = min_chars || Llmemory.configuration.noise_filter_min_chars
|
|
10
|
+
@enabled = enabled
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def filter(conversation_text)
|
|
14
|
+
return conversation_text.to_s unless @enabled
|
|
15
|
+
|
|
16
|
+
lines = conversation_text.to_s.split("\n")
|
|
17
|
+
seen = {}
|
|
18
|
+
filtered = lines.select do |line|
|
|
19
|
+
next false if line.strip.length < @min_chars
|
|
20
|
+
next false if line.include?(NO_REPLY_MARKER)
|
|
21
|
+
next false if seen[line.strip]
|
|
22
|
+
|
|
23
|
+
seen[line.strip] = true
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
filtered.join("\n").strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.filter?(conversation_text)
|
|
31
|
+
return conversation_text.to_s unless Llmemory.configuration.noise_filter_enabled
|
|
32
|
+
|
|
33
|
+
new.filter(conversation_text)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -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
|
|
|
@@ -47,7 +53,8 @@ module Llmemory
|
|
|
47
53
|
{
|
|
48
54
|
text: c[:text] || c["text"],
|
|
49
55
|
timestamp: parse_timestamp(c[:timestamp] || c["timestamp"] || c[:created_at] || c["created_at"]),
|
|
50
|
-
score: (c[:score] || c["score"] || 1.0).to_f
|
|
56
|
+
score: (c[:score] || c["score"] || 1.0).to_f,
|
|
57
|
+
evergreen: c[:evergreen] || c["evergreen"]
|
|
51
58
|
}
|
|
52
59
|
end
|
|
53
60
|
end
|
|
@@ -58,6 +65,21 @@ module Llmemory
|
|
|
58
65
|
Time.now
|
|
59
66
|
end
|
|
60
67
|
|
|
68
|
+
def apply_hybrid_scoring(candidates, query)
|
|
69
|
+
return candidates if candidates.empty?
|
|
70
|
+
|
|
71
|
+
scored = @bm25_scorer.score_candidates(query, candidates)
|
|
72
|
+
weight = Llmemory.configuration.bm25_weight.to_f
|
|
73
|
+
weight = 0.3 if weight < 0 || weight > 1
|
|
74
|
+
|
|
75
|
+
scored.map do |c|
|
|
76
|
+
vector_score = (c[:score] || c["score"] || 1.0).to_f
|
|
77
|
+
bm25_norm = (c[:normalized_bm25] || 0).to_f
|
|
78
|
+
hybrid = weight * bm25_norm + (1 - weight) * vector_score
|
|
79
|
+
c.merge(score: hybrid)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
61
83
|
def filter_by_relevance(candidates, user_message)
|
|
62
84
|
return candidates if candidates.size <= 5
|
|
63
85
|
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
|
|
@@ -8,12 +8,20 @@ module Llmemory
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def rank(candidates, now: Time.now)
|
|
11
|
+
lambda_val = Math.log(2) / @half_life_days.to_f
|
|
12
|
+
|
|
11
13
|
candidates.map do |c|
|
|
12
14
|
score = (c[:score] || c["score"] || 1.0).to_f
|
|
13
15
|
timestamp = c[:timestamp] || c["timestamp"]
|
|
14
16
|
timestamp = Time.parse(timestamp.to_s) if timestamp.is_a?(String)
|
|
15
17
|
age_days = timestamp ? (now - timestamp).to_i / 86400 : 0
|
|
16
|
-
|
|
18
|
+
|
|
19
|
+
time_decay = if c[:evergreen] || c["evergreen"]
|
|
20
|
+
1.0
|
|
21
|
+
else
|
|
22
|
+
Math.exp(-lambda_val * age_days.to_f)
|
|
23
|
+
end
|
|
24
|
+
|
|
17
25
|
final_score = score * time_decay
|
|
18
26
|
c.merge(score: score, temporal_score: final_score, timestamp: timestamp)
|
|
19
27
|
end.sort_by { |c| -(c[:temporal_score] || 0) }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ShortTerm
|
|
5
|
+
class MessageSanitizer
|
|
6
|
+
def initialize(max_message_chars: nil)
|
|
7
|
+
@max_chars = max_message_chars || Llmemory.configuration.max_message_chars
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def sanitize!(messages)
|
|
11
|
+
return [] if messages.nil? || !messages.is_a?(Array)
|
|
12
|
+
|
|
13
|
+
out = []
|
|
14
|
+
expect_tool_result = false
|
|
15
|
+
|
|
16
|
+
messages.each do |msg|
|
|
17
|
+
msg = msg.dup
|
|
18
|
+
content = (msg[:content] || msg["content"]).to_s
|
|
19
|
+
role = (msg[:role] || msg["role"]).to_s
|
|
20
|
+
|
|
21
|
+
next if content.strip.empty?
|
|
22
|
+
|
|
23
|
+
content = content[0, @max_chars] if @max_chars && content.length > @max_chars
|
|
24
|
+
|
|
25
|
+
if role == "tool"
|
|
26
|
+
expect_tool_result = true
|
|
27
|
+
elsif role == "tool_result"
|
|
28
|
+
next unless expect_tool_result
|
|
29
|
+
expect_tool_result = false
|
|
30
|
+
else
|
|
31
|
+
expect_tool_result = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
msg[:content] = content if msg.key?(:content)
|
|
35
|
+
msg["content"] = content if msg.key?("content")
|
|
36
|
+
out << msg
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
out
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
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
|