llmemory 0.1.12 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33ff7cbff0694be426fa937233f3429688759f595e7522a0bc3c22aa9dcb6214
4
- data.tar.gz: 53ccabdcd6706037e63574bf97cd575bf5310c1cde96dd794e83d38ec0ed3606
3
+ metadata.gz: 3bdfe8a5f7301af319a99e853c36646491cdc64c581b566811605fb4e63415dd
4
+ data.tar.gz: 27d827d262c35f3e42757416895fe91ad84290032f0091dbfd7a24806da5cf73
5
5
  SHA512:
6
- metadata.gz: 463b3028747f9ff47b9949325d7461b41ad231c0798801269b0f083c96455eb347714f6fc6fb5e96fdede30897b8753e46d514944f2ecf3fc097ccd719b637e1
7
- data.tar.gz: dcd0da448d5b2ad4e686552b7cb9271fac36b58882cf3fd97761effde76851f4c193b1edbc8f50e46f1289499b4fe2c24cb06f6ba3093cab6a8e731982e60225
6
+ metadata.gz: 72337abbc9bd02e9289a9b4dad399741f066fc9dbaf01cd2fa8de80813f4042c8939313afb7aa248addf02ade1cdae379a93585618200f96c3e2d0dbafef9138
7
+ data.tar.gz: 263872146fb6654ecfef977b7a7075798b6439e06405eeb512ff48b67c7294387eb963421cbffed1fecf0e4863f7bfc82b0d6f88f3e92eb8cc06898d587c50b2
data/README.md CHANGED
@@ -33,6 +33,9 @@ context = memory.retrieve("¿Qué preferencias tiene el usuario?", max_tokens: 2
33
33
  # Optionally consolidate current conversation into long-term (extract facts)
34
34
  memory.consolidate!
35
35
 
36
+ # Compact short-term memory when it gets too large (summarizes old messages)
37
+ memory.compact!(max_bytes: 8192) # or use config default
38
+
36
39
  # Clear session (short-term) while keeping long-term intact
37
40
  memory.clear_session!
38
41
  ```
@@ -41,6 +44,7 @@ memory.clear_session!
41
44
  - **`messages`** — Returns the current conversation history.
42
45
  - **`retrieve(query, max_tokens: nil)`** — Returns combined context: recent conversation + relevant long-term memories.
43
46
  - **`consolidate!`** — Extracts facts from the current conversation and stores them in long-term.
47
+ - **`compact!(max_bytes: nil)`** — Compacts short-term memory by summarizing old messages when byte size exceeds limit. Uses LLM to create a summary, keeping recent messages intact.
44
48
  - **`clear_session!`** — Clears short-term only.
45
49
 
46
50
  ## Configuration
@@ -59,6 +63,7 @@ Llmemory.configure do |config|
59
63
  config.time_decay_half_life_days = 30
60
64
  config.max_retrieval_tokens = 2000
61
65
  config.prune_after_days = 90
66
+ config.compact_max_bytes = 8192 # max bytes before compact! triggers
62
67
  end
63
68
  ```
64
69
 
@@ -15,7 +15,25 @@ module Llmemory
15
15
  :vector_store,
16
16
  :time_decay_half_life_days,
17
17
  :max_retrieval_tokens,
18
- :prune_after_days
18
+ :prune_after_days,
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
19
37
 
20
38
  def initialize
21
39
  @llm_provider = :openai
@@ -32,6 +50,24 @@ module Llmemory
32
50
  @time_decay_half_life_days = 30
33
51
  @max_retrieval_tokens = 2000
34
52
  @prune_after_days = 90
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
35
71
  end
36
72
  end
37
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"]
@@ -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
- short_context = format_short_term_context(messages)
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?
@@ -52,12 +84,133 @@ module Llmemory
52
84
  true
53
85
  end
54
86
 
87
+ def compact!(max_bytes: nil)
88
+ max = max_bytes || Llmemory.configuration.compact_max_bytes
89
+ msgs = messages
90
+ current_bytes = messages_byte_size(msgs)
91
+ return false if current_bytes <= max
92
+
93
+ flush_memory_before_compaction!(msgs)
94
+
95
+ old_msgs, recent_msgs = split_messages_by_bytes(msgs, max)
96
+ return false if old_msgs.empty?
97
+
98
+ summary = summarize_messages(old_msgs)
99
+ compacted = [{ role: :system, content: summary }] + recent_msgs
100
+ save_state(messages: compacted)
101
+ true
102
+ end
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
+
55
146
  def user_id
56
147
  @user_id
57
148
  end
58
149
 
59
150
  private
60
151
 
152
+ def summarize_messages(msgs)
153
+ conversation = msgs.map { |m| format_message(m) }.join("\n")
154
+ prompt = <<~PROMPT
155
+ Summarize the following conversation into a concise summary that preserves key information, decisions, and context. Write it as a brief narrative (max 200 words).
156
+
157
+ Conversation:
158
+ #{conversation}
159
+
160
+ Summary:
161
+ PROMPT
162
+ llm_client.invoke(prompt.strip).to_s.strip
163
+ rescue Llmemory::LLMError
164
+ msgs.map { |m| format_message(m) }.join("\n")[0..500]
165
+ end
166
+
167
+ def llm_client
168
+ @llm ||= Llmemory::LLM.client
169
+ end
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
+
183
+ def messages_byte_size(msgs)
184
+ msgs.sum { |m| message_byte_size(m) }
185
+ end
186
+
187
+ def message_byte_size(msg)
188
+ role = msg[:role] || msg["role"]
189
+ content = msg[:content] || msg["content"]
190
+ role.to_s.bytesize + content.to_s.bytesize
191
+ end
192
+
193
+ def split_messages_by_bytes(msgs, max_bytes)
194
+ target_recent_bytes = max_bytes / 2
195
+ recent_bytes = 0
196
+ split_index = msgs.size
197
+
198
+ (msgs.size - 1).downto(0) do |i|
199
+ msg_bytes = message_byte_size(msgs[i])
200
+ if recent_bytes + msg_bytes <= target_recent_bytes
201
+ recent_bytes += msg_bytes
202
+ split_index = i
203
+ else
204
+ break
205
+ end
206
+ end
207
+
208
+ split_index = [split_index, msgs.size - 1].min
209
+ split_index = [split_index, 1].max if msgs.size > 1
210
+
211
+ [msgs[0...split_index], msgs[split_index..]]
212
+ end
213
+
61
214
  def build_long_term(long_term_type)
62
215
  llm_opts = @llm ? { llm: @llm } : {}
63
216
  case long_term_type.to_s.to_sym
@@ -73,7 +226,17 @@ module Llmemory
73
226
  end
74
227
 
75
228
  def save_state(messages:)
76
- @checkpoint.save_state(STATE_KEY_MESSAGES => messages)
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)
77
240
  end
78
241
 
79
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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "short_term/checkpoint"
4
+ require_relative "short_term/session_lifecycle"
4
5
 
5
6
  module Llmemory
6
7
  module ShortTerm
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.1.12"
4
+ VERSION = "0.1.14"
5
5
  end
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.12
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