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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a188251e04aac90f929fcc952f7902a8e9b86728742ab88e5f209953821efd30
4
- data.tar.gz: 01f3cd9d68c50a1a52ed33683b973a379073890db1ae5a3073676fd598581eca
3
+ metadata.gz: 3bdfe8a5f7301af319a99e853c36646491cdc64c581b566811605fb4e63415dd
4
+ data.tar.gz: 27d827d262c35f3e42757416895fe91ad84290032f0091dbfd7a24806da5cf73
5
5
  SHA512:
6
- metadata.gz: e24d411ffaca985dc360bc6a6d271f8fd57c687cef26c8242e047fab6bcda400b3764b78aeea8d58ec253bc33ce48b89a46d6a8bdb71db8152c4d175880f3b87
7
- data.tar.gz: d7097e4c6cc8442088f5adb0614015b8d9d0541db8f4843e729c8e94cfefe0faf49969e9abb75381b070386c53623631b937fc72989a1f7cb0aa27b3b45a9171
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"]
@@ -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?
@@ -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
- @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)
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
@@ -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.13"
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.13
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