llmemory 0.1.14 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bdfe8a5f7301af319a99e853c36646491cdc64c581b566811605fb4e63415dd
4
- data.tar.gz: 27d827d262c35f3e42757416895fe91ad84290032f0091dbfd7a24806da5cf73
3
+ metadata.gz: 723fae20d0310ccaeaf9ba600148061d17b2a0b29f933d455d1cf656dee85636
4
+ data.tar.gz: a135ea1661af46e96843bf52744e8004d0ebe7e8d94b0c46a097c36df53d5bc4
5
5
  SHA512:
6
- metadata.gz: 72337abbc9bd02e9289a9b4dad399741f066fc9dbaf01cd2fa8de80813f4042c8939313afb7aa248addf02ade1cdae379a93585618200f96c3e2d0dbafef9138
7
- data.tar.gz: 263872146fb6654ecfef977b7a7075798b6439e06405eeb512ff48b67c7294387eb963421cbffed1fecf0e4863f7bfc82b0d6f88f3e92eb8cc06898d587c50b2
6
+ metadata.gz: 256caaee94233d5e57b8d9e6007fe1ced57d35e21d40260ce34b2803ba0ef3593b66668aa06334e647edd103aa431113e38b639776163d71153c4b9bac68c1a1
7
+ data.tar.gz: 33cd1726e9f7bb3328610bafabca5ebfe51f080e7d34c523fc0b363eb290b353c9109937f2782ed7e60906965236e79229838e32c698d2f0e2f73aa2d421970b
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Persistent memory system for LLM agents. Implements short-term checkpointing, long-term memory (file-based or **graph-based**), retrieval with time decay, and maintenance jobs. You can inspect memory from the **CLI** or, in Rails apps, from an optional **dashboard**.
4
4
 
5
+ Includes advanced memory management features inspired by [OpenClaw](https://github.com/openclaw/openclaw): pre-compaction memory flush, hybrid search (BM25 + vector), tool result pruning, context window tracking, session lifecycle management, daily memory logs, and auto-recall.
6
+
5
7
  ## Installation
6
8
 
7
9
  Add to your Gemfile:
@@ -40,11 +42,14 @@ memory.compact!(max_bytes: 8192) # or use config default
40
42
  memory.clear_session!
41
43
  ```
42
44
 
43
- - **`add_message(role:, content:)`** — Persists messages in short-term.
45
+ - **`add_message(role:, content:)`** — Persists messages in short-term. Supports `user`, `assistant`, `system`, `tool`, and `tool_result` roles.
44
46
  - **`messages`** — Returns the current conversation history.
45
47
  - **`retrieve(query, max_tokens: nil)`** — Returns combined context: recent conversation + relevant long-term memories.
48
+ - **`recall_for(query: nil)`** — Auto-recall: returns context for the given query (or last user message if `query` is nil). Only active when `auto_recall_enabled` is true.
46
49
  - **`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.
50
+ - **`compact!(max_bytes: nil)`** — Compacts short-term memory by summarizing old messages when byte size exceeds limit. Automatically flushes to long-term before compacting when over `memory_flush_threshold_tokens`.
51
+ - **`prune!(mode: nil)`** — Prunes oversized tool results (soft-trim or hard-clear). Only when `prune_tool_results_enabled` is true.
52
+ - **`check_context_window!`** — Triggers consolidate and compact when context exceeds configured thresholds.
48
53
  - **`clear_session!`** — Clears short-term only.
49
54
 
50
55
  ## Configuration
@@ -64,6 +69,37 @@ Llmemory.configure do |config|
64
69
  config.max_retrieval_tokens = 2000
65
70
  config.prune_after_days = 90
66
71
  config.compact_max_bytes = 8192 # max bytes before compact! triggers
72
+
73
+ # Pre-compaction memory flush (prevents knowledge loss when compacting)
74
+ config.memory_flush_enabled = true
75
+ config.memory_flush_threshold_tokens = 4000
76
+
77
+ # Hybrid search (BM25 + vector) and MMR re-ranking
78
+ config.hybrid_search_enabled = true
79
+ config.bm25_weight = 0.3
80
+ config.mmr_enabled = false
81
+ config.mmr_lambda = 0.7
82
+
83
+ # Tool result pruning (soft-trim or hard-clear for tool/tool_result messages)
84
+ config.prune_tool_results_enabled = false
85
+ config.prune_tool_results_mode = :soft_trim
86
+ config.prune_tool_results_max_bytes = 2048
87
+
88
+ # Context window tracking and auto-consolidation
89
+ config.context_window_tokens = 128_000
90
+ config.reserve_tokens = 16_384
91
+ config.keep_recent_tokens = 20_000
92
+
93
+ # Session lifecycle management
94
+ config.session_idle_minutes = 60
95
+ config.session_prune_after_days = 30
96
+ config.session_max_entries_per_user = 500
97
+
98
+ # Daily memory logs (file-based, FileStorage only)
99
+ config.daily_logs_enabled = false
100
+
101
+ # Auto-recall (inject relevant memories before each LLM turn)
102
+ config.auto_recall_enabled = false
67
103
  end
68
104
  ```
69
105
 
@@ -159,6 +195,71 @@ candidates = memory.search_candidates("job", top_k: 20)
159
195
 
160
196
  **Graph storage:** `:memory` (in-memory) or `:active_record` (Rails). For ActiveRecord, run `rails g llmemory:install` and migrate; the migration creates `llmemory_nodes`, `llmemory_edges`, and `llmemory_embeddings` (pgvector). Enable the `vector` extension in PostgreSQL for embeddings.
161
197
 
198
+ ## Advanced Memory Management
199
+
200
+ These features improve robustness and efficiency, inspired by OpenClaw's memory system.
201
+
202
+ ### Pre-Compaction Memory Flush
203
+
204
+ Before compacting short-term memory, llmemory can automatically consolidate the conversation into long-term storage. This prevents knowledge loss when the context is summarized.
205
+
206
+ - **`memory_flush_enabled`** — When true, `compact!` calls `consolidate!` first when messages exceed `memory_flush_threshold_tokens`.
207
+ - **`maybe_flush_memory!`** — Call explicitly to flush when approaching context limits.
208
+
209
+ ### Hybrid Search (BM25 + Vector)
210
+
211
+ Retrieval combines keyword matching (BM25) with vector similarity for more robust search. Optional MMR (Maximal Marginal Relevance) re-ranking improves result diversity.
212
+
213
+ - **`hybrid_search_enabled`** — Combines BM25 and vector scores.
214
+ - **`bm25_weight`** — Weight for BM25 (0–1); remainder is vector score.
215
+ - **`mmr_enabled`** — Re-ranks results for diversity.
216
+ - **`mmr_lambda`** — Balance between relevance and diversity (0–1).
217
+
218
+ ### Tool Result Pruning
219
+
220
+ Large tool outputs can consume most of the context window. Pruning selectively trims `tool` and `tool_result` messages while keeping user/assistant intact.
221
+
222
+ - **`prune_tool_results_enabled`** — When true, `retrieve` uses pruned messages and `prune!` is available.
223
+ - **`prune_tool_results_mode`** — `:soft_trim` (keep head+tail) or `:hard_clear` (replace with placeholder).
224
+ - **`prune_tool_results_max_bytes`** — Max bytes before soft-trim applies.
225
+
226
+ ### Context Window Tracking
227
+
228
+ Track estimated tokens and trigger consolidation/compaction automatically.
229
+
230
+ - **`context_tokens`** — Returns estimated token count for current messages.
231
+ - **`should_auto_consolidate?`** — True when over `context_window_tokens - reserve_tokens`.
232
+ - **`check_context_window!`** — Runs consolidate and compact when thresholds are exceeded.
233
+
234
+ ### Session Lifecycle Management
235
+
236
+ Clean up stale or idle sessions to control storage usage.
237
+
238
+ ```ruby
239
+ lifecycle = Llmemory::ShortTerm::SessionLifecycle.new
240
+ lifecycle.cleanup_idle_sessions!(user_id: "user_123", idle_minutes: 60)
241
+ lifecycle.cleanup_stale_sessions!(user_id: "user_123", prune_after_days: 30)
242
+ lifecycle.enforce_max_entries!(user_id: "user_123", max_entries: 500)
243
+ ```
244
+
245
+ Sessions store `last_activity_at` automatically on each save.
246
+
247
+ ### Daily Memory Logs
248
+
249
+ With `daily_logs_enabled` and FileStorage, file-based memory writes to `memory/YYYY-MM-DD.md` per user. Today's and yesterday's logs are included in retrieval. Useful for temporal organization and human-readable logs.
250
+
251
+ ### Auto-Recall
252
+
253
+ When `auto_recall_enabled` is true, call `recall_for(query: nil)` before each LLM turn. If `query` is nil, the last user message is used as the search query. Returns combined context without explicit `retrieve` calls.
254
+
255
+ ```ruby
256
+ Llmemory.configure { |c| c.auto_recall_enabled = true }
257
+ # Before each LLM call:
258
+ context = memory.recall_for(query: user_message)
259
+ # Or use last user message automatically:
260
+ context = memory.recall_for
261
+ ```
262
+
162
263
  ## Lower-Level APIs
163
264
 
164
265
  ### Short-Term Memory (Checkpointing)
@@ -333,7 +434,7 @@ MCP_TOKEN=your-secret-token llmemory mcp serve --http --port 443 \
333
434
  | `memory_retrieve` | Get context optimized for LLM inference (supports timeline context) |
334
435
  | `memory_timeline` | Get chronological timeline of recent memories |
335
436
  | `memory_timeline_context` | Get N items before/after a specific memory |
336
- | `memory_add_message` | Add message to short-term conversation |
437
+ | `memory_add_message` | Add message to short-term conversation (roles: user, assistant, system, tool, tool_result) |
337
438
  | `memory_consolidate` | Extract facts from conversation to long-term |
338
439
  | `memory_stats` | Get memory statistics for a user |
339
440
  | `memory_info` | Documentation on how to use the tools |
@@ -16,6 +16,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
16
16
  t.string :category, null: false
17
17
  t.text :content, null: false
18
18
  t.string :source_resource_id
19
+ t.float :importance, default: 0.7
19
20
  t.timestamps
20
21
  end
21
22
  add_index :llmemory_items, :user_id
@@ -33,7 +33,15 @@ module Llmemory
33
33
  :session_prune_after_days,
34
34
  :session_max_entries_per_user,
35
35
  :daily_logs_enabled,
36
- :auto_recall_enabled
36
+ :auto_recall_enabled,
37
+ :noise_filter_enabled,
38
+ :noise_filter_min_chars,
39
+ :flush_once_per_cycle_seconds,
40
+ :overflow_recovery_enabled,
41
+ :embedding_cache_enabled,
42
+ :embedding_cache_max_entries,
43
+ :max_message_chars,
44
+ :message_sanitizer_enabled
37
45
 
38
46
  def initialize
39
47
  @llm_provider = :openai
@@ -68,6 +76,14 @@ module Llmemory
68
76
  @session_max_entries_per_user = 500
69
77
  @daily_logs_enabled = false
70
78
  @auto_recall_enabled = false
79
+ @noise_filter_enabled = false
80
+ @noise_filter_min_chars = 10
81
+ @flush_once_per_cycle_seconds = 60
82
+ @overflow_recovery_enabled = false
83
+ @embedding_cache_enabled = true
84
+ @embedding_cache_max_entries = 10_000
85
+ @max_message_chars = 32_000
86
+ @message_sanitizer_enabled = false
71
87
  end
72
88
  end
73
89
 
@@ -14,7 +14,9 @@ module Llmemory
14
14
  Extract discrete facts from this conversation.
15
15
  Focus on preferences, behaviors, and important details.
16
16
  Conversation: #{conversation_text}
17
- Return as JSON array of objects with "content" key. Example: [{"content": "User prefers Ruby"}, {"content": "User is vegan"}]
17
+ Return as JSON array of objects with "content" and "importance" (0-1) keys.
18
+ Importance: 0.8-0.95 for preferences/corrections/decisions, 0.5-0.8 for factual context, 0.3-0.5 for ephemeral.
19
+ Example: [{"content": "User prefers Ruby", "importance": 0.9}, {"content": "User mentioned the weather", "importance": 0.4}]
18
20
  PROMPT
19
21
  response = @llm.invoke(prompt.strip)
20
22
  parse_items_response(response)
@@ -56,7 +58,12 @@ module Llmemory
56
58
  def parse_items_response(response)
57
59
  json = extract_json_array(response)
58
60
  return [] unless json
59
- json.map { |item| item.is_a?(Hash) ? item : { "content" => item.to_s } }
61
+ json.map do |item|
62
+ h = item.is_a?(Hash) ? item : { "content" => item.to_s }
63
+ imp = h["importance"] || h[:importance]
64
+ h["importance"] = imp.nil? ? 0.7 : (imp.to_f.between?(0, 1) ? imp.to_f : 0.7)
65
+ h
66
+ end
60
67
  end
61
68
 
62
69
  def extract_json_array(response)
@@ -4,6 +4,7 @@ require_relative "resource"
4
4
  require_relative "item"
5
5
  require_relative "category"
6
6
  require_relative "storage"
7
+ require_relative "../../noise_filter"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
@@ -17,17 +18,21 @@ module Llmemory
17
18
  end
18
19
 
19
20
  def memorize(conversation_text)
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)
22
- items = @extractor.extract_items(conversation_text)
21
+ text = Llmemory.configuration.noise_filter_enabled ? NoiseFilter.filter?(conversation_text) : conversation_text.to_s
22
+ return true if text.strip.empty?
23
+
24
+ resource_id = save_resource(text)
25
+ append_to_daily_log(text) if Llmemory.configuration.daily_logs_enabled && @storage.respond_to?(:save_daily_log_entry)
26
+ items = @extractor.extract_items(text)
23
27
  updates_by_category = {}
24
28
 
25
29
  items.each do |item|
26
30
  content = item.is_a?(Hash) ? (item["content"] || item[:content]) : item.to_s
31
+ importance = (item["importance"] || item[:importance] || 0.7).to_f
27
32
  cat = @extractor.classify_item(content)
28
33
  updates_by_category[cat] ||= []
29
34
  updates_by_category[cat] << content.to_s
30
- save_item(category: cat, item: item, source_resource_id: resource_id)
35
+ save_item(category: cat, item: item, source_resource_id: resource_id, importance: importance)
31
36
  end
32
37
 
33
38
  updates_by_category.each do |category, new_memories|
@@ -49,12 +54,19 @@ module Llmemory
49
54
  items = @storage.search_items(uid, query)
50
55
  resources = @storage.search_resources(uid, query)
51
56
  daily_logs = load_daily_logs_for_retrieval(uid) if Llmemory.configuration.daily_logs_enabled && @storage.respond_to?(:load_daily_logs)
57
+ category_summaries = load_category_summaries_as_candidates(uid, query)
52
58
  out = []
59
+
60
+ category_summaries.each do |c|
61
+ out << c.merge(evergreen: true)
62
+ end
63
+
53
64
  items.first(top_k).each do |i|
54
65
  out << {
55
66
  text: i[:content] || i["content"],
56
67
  timestamp: i[:created_at] || i["created_at"],
57
- score: 1.0
68
+ score: (i[:importance] || i["importance"] || 1.0).to_f,
69
+ evergreen: i[:evergreen] || i["evergreen"]
58
70
  }
59
71
  end
60
72
  resources.first([top_k - out.size, 0].max).each do |r|
@@ -80,9 +92,9 @@ module Llmemory
80
92
  @storage.save_resource(@user_id, text)
81
93
  end
82
94
 
83
- def save_item(category:, item:, source_resource_id:)
95
+ def save_item(category:, item:, source_resource_id:, importance: 0.7)
84
96
  content = item.is_a?(Hash) ? item["content"] || item[:content] : item.to_s
85
- @storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id)
97
+ @storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id, importance: importance)
86
98
  end
87
99
 
88
100
  def append_to_daily_log(conversation_text)
@@ -96,6 +108,22 @@ module Llmemory
96
108
  logs = @storage.load_daily_logs(user_id, from_date: yesterday, to_date: today)
97
109
  logs.map { |l| { date: l[:date], content: "[#{l[:date]}] #{l[:content]}" } }
98
110
  end
111
+
112
+ def load_category_summaries_as_candidates(user_id, query)
113
+ return [] unless @storage.respond_to?(:list_categories)
114
+
115
+ categories = @storage.list_categories(user_id)
116
+ return [] if categories.empty?
117
+
118
+ query_lower = query.to_s.downcase
119
+ categories.filter_map do |cat|
120
+ summary = @storage.load_category(user_id, cat)
121
+ next if summary.to_s.strip.empty?
122
+ next unless summary.to_s.downcase.include?(query_lower)
123
+
124
+ { text: "[#{cat}] #{summary}", timestamp: Time.now, score: 0.95 }
125
+ end
126
+ end
99
127
  end
100
128
  end
101
129
  end
@@ -30,16 +30,18 @@ module Llmemory
30
30
  id
31
31
  end
32
32
 
33
- def save_item(user_id, category:, content:, source_resource_id:)
33
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
34
34
  id = "item_#{SecureRandom.hex(8)}"
35
- LlmemoryItem.create!(
35
+ attrs = {
36
36
  id: id,
37
37
  user_id: user_id,
38
38
  category: category,
39
39
  content: content,
40
40
  source_resource_id: source_resource_id,
41
41
  created_at: Time.current
42
- )
42
+ }
43
+ attrs[:importance] = importance if LlmemoryItem.column_names.include?("importance")
44
+ LlmemoryItem.create!(attrs)
43
45
  id
44
46
  end
45
47
 
@@ -96,14 +98,16 @@ module Llmemory
96
98
  def replace_items(user_id, ids_to_remove, merged_item)
97
99
  LlmemoryItem.where(user_id: user_id, id: ids_to_remove).destroy_all
98
100
  created_at = merged_item[:created_at] || Time.current
99
- LlmemoryItem.create!(
101
+ attrs = {
100
102
  id: "item_#{SecureRandom.hex(8)}",
101
103
  user_id: user_id,
102
104
  category: merged_item[:category],
103
105
  content: merged_item[:content],
104
106
  source_resource_id: merged_item[:source_resource_id],
105
107
  created_at: created_at
106
- )
108
+ }
109
+ attrs[:importance] = merged_item[:importance] if LlmemoryItem.column_names.include?("importance") && merged_item[:importance]
110
+ LlmemoryItem.create!(attrs)
107
111
  end
108
112
 
109
113
  def archive_items(user_id, item_ids)
@@ -177,13 +181,15 @@ module Llmemory
177
181
  end
178
182
 
179
183
  def row_to_item(r)
180
- {
184
+ h = {
181
185
  id: r.id,
182
186
  category: r.category,
183
187
  content: r.content,
184
188
  source_resource_id: r.source_resource_id,
185
189
  created_at: r.created_at
186
190
  }
191
+ h[:importance] = r.respond_to?(:importance) ? (r.importance || 0.7).to_f : 0.7
192
+ h
187
193
  end
188
194
 
189
195
  def row_to_resource(r)
@@ -9,7 +9,7 @@ module Llmemory
9
9
  raise NotImplementedError, "#{self.class}#save_resource must be implemented"
10
10
  end
11
11
 
12
- def save_item(user_id, category:, content:, source_resource_id:)
12
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
13
13
  raise NotImplementedError, "#{self.class}#save_item must be implemented"
14
14
  end
15
15
 
@@ -24,12 +24,12 @@ module Llmemory
24
24
  id
25
25
  end
26
26
 
27
- def save_item(user_id, category:, content:, source_resource_id:)
27
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
28
28
  ensure_tables!
29
29
  id = "item_#{SecureRandom.hex(8)}"
30
30
  conn.exec_params(
31
- "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
32
- [id, user_id, category, content, source_resource_id, Time.now.utc.iso8601]
31
+ "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, importance, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
32
+ [id, user_id, category, content, source_resource_id, importance.to_f, Time.now.utc.iso8601]
33
33
  )
34
34
  id
35
35
  end
@@ -67,7 +67,7 @@ module Llmemory
67
67
  ensure_tables!
68
68
  pattern = "%#{conn.escape_string(query.to_s.downcase)}%"
69
69
  rows = conn.exec_params(
70
- "SELECT id, category, content, source_resource_id, created_at FROM llmemory_items WHERE user_id = $1 AND LOWER(content) LIKE $2",
70
+ "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 AND LOWER(content) LIKE $2",
71
71
  [user_id, pattern]
72
72
  )
73
73
  rows_to_items(rows)
@@ -97,7 +97,7 @@ module Llmemory
97
97
  ensure_tables!
98
98
  cutoff = (Time.now - (days * 86400)).utc.iso8601
99
99
  rows = conn.exec_params(
100
- "SELECT id, category, content, source_resource_id, created_at FROM llmemory_items WHERE user_id = $1 AND created_at < $2 ORDER BY created_at",
100
+ "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 AND created_at < $2 ORDER BY created_at",
101
101
  [user_id, cutoff]
102
102
  )
103
103
  rows_to_items(rows)
@@ -106,7 +106,7 @@ module Llmemory
106
106
  def get_all_items(user_id)
107
107
  ensure_tables!
108
108
  rows = conn.exec_params(
109
- "SELECT id, category, content, source_resource_id, created_at FROM llmemory_items WHERE user_id = $1 ORDER BY created_at",
109
+ "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 ORDER BY created_at",
110
110
  [user_id]
111
111
  )
112
112
  rows_to_items(rows)
@@ -125,7 +125,7 @@ module Llmemory
125
125
  ensure_tables!
126
126
  cutoff = (Time.now - (hours * 3600)).utc.iso8601
127
127
  rows = conn.exec_params(
128
- "SELECT id, category, content, source_resource_id, created_at FROM llmemory_items WHERE user_id = $1 AND created_at >= $2 ORDER BY created_at",
128
+ "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 AND created_at >= $2 ORDER BY created_at",
129
129
  [user_id, cutoff]
130
130
  )
131
131
  rows_to_items(rows)
@@ -179,7 +179,7 @@ module Llmemory
179
179
 
180
180
  def list_items(user_id:, category: nil, limit: nil)
181
181
  ensure_tables!
182
- sql = "SELECT id, category, content, source_resource_id, created_at FROM llmemory_items WHERE user_id = $1"
182
+ sql = "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1"
183
183
  params = [user_id]
184
184
  if category
185
185
  sql += " AND category = $2"
@@ -257,10 +257,12 @@ module Llmemory
257
257
  category TEXT NOT NULL,
258
258
  content TEXT NOT NULL,
259
259
  source_resource_id TEXT,
260
+ importance REAL DEFAULT 0.7,
260
261
  created_at TIMESTAMPTZ NOT NULL
261
262
  );
262
263
  CREATE INDEX IF NOT EXISTS idx_llmemory_items_user_id ON llmemory_items(user_id);
263
264
  SQL
265
+ conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS importance REAL DEFAULT 0.7") rescue nil
264
266
  conn.exec(<<~SQL)
265
267
  CREATE TABLE IF NOT EXISTS llmemory_categories (
266
268
  user_id TEXT NOT NULL,
@@ -279,6 +281,7 @@ module Llmemory
279
281
  category: r["category"],
280
282
  content: r["content"],
281
283
  source_resource_id: r["source_resource_id"],
284
+ importance: (r["importance"] || 0.7).to_f,
282
285
  created_at: Time.parse(r["created_at"])
283
286
  }
284
287
  end
@@ -24,7 +24,7 @@ module Llmemory
24
24
  id
25
25
  end
26
26
 
27
- def save_item(user_id, category:, content:, source_resource_id:)
27
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
28
28
  ensure_user_dir(user_id)
29
29
  seq = next_seq(user_id, "item_id_seq")
30
30
  id = "item_#{seq}"
@@ -34,6 +34,7 @@ module Llmemory
34
34
  category: category,
35
35
  content: content,
36
36
  source_resource_id: source_resource_id,
37
+ importance: importance,
37
38
  created_at: Time.now.iso8601
38
39
  }
39
40
  File.write(path, JSON.generate(data))
@@ -22,7 +22,7 @@ module Llmemory
22
22
  id
23
23
  end
24
24
 
25
- def save_item(user_id, category:, content:, source_resource_id:)
25
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
26
26
  @item_id_seq += 1
27
27
  id = "item_#{@item_id_seq}"
28
28
  @items[user_id] << {
@@ -30,6 +30,7 @@ module Llmemory
30
30
  category: category,
31
31
  content: content,
32
32
  source_resource_id: source_resource_id,
33
+ importance: importance,
33
34
  created_at: Time.now
34
35
  }
35
36
  id
@@ -5,6 +5,7 @@ require_relative "edge"
5
5
  require_relative "knowledge_graph"
6
6
  require_relative "conflict_resolver"
7
7
  require_relative "storage"
8
+ require_relative "../../noise_filter"
8
9
 
9
10
  module Llmemory
10
11
  module LongTerm
@@ -21,7 +22,10 @@ module Llmemory
21
22
  end
22
23
 
23
24
  def memorize(conversation_text)
24
- data = @extractor.extract(conversation_text) rescue { entities: [], relations: [] }
25
+ text = Llmemory.configuration.noise_filter_enabled ? NoiseFilter.filter?(conversation_text) : conversation_text.to_s
26
+ return true if text.strip.empty?
27
+
28
+ data = @extractor.extract(text) rescue { entities: [], relations: [] }
25
29
  data = { entities: [], relations: [] } unless data.is_a?(Hash)
26
30
  entities = Array(data[:entities] || data["entities"])
27
31
  relations = Array(data[:relations] || data["relations"])
@@ -23,7 +23,7 @@ module Llmemory
23
23
  def add_message(role:, content:)
24
24
  msgs = messages
25
25
  msgs << { role: role.to_sym, content: content.to_s }
26
- save_state(messages: msgs)
26
+ save_state(messages: msgs, **preserved_flush_state)
27
27
  true
28
28
  end
29
29
 
@@ -31,7 +31,8 @@ module Llmemory
31
31
  state = @checkpoint.restore_state
32
32
  return [] unless state.is_a?(Hash)
33
33
  list = state[STATE_KEY_MESSAGES] || state[STATE_KEY_MESSAGES.to_s]
34
- list.is_a?(Array) ? list.dup : []
34
+ list = list.is_a?(Array) ? list.dup : []
35
+ sanitize_messages(list)
35
36
  end
36
37
 
37
38
  def retrieve(query, max_tokens: nil)
@@ -67,7 +68,7 @@ module Llmemory
67
68
  soft_trim_max_bytes: Llmemory.configuration.prune_tool_results_max_bytes
68
69
  )
69
70
  pruned = pruner.prune!(msgs, mode: mode)
70
- save_state(messages: pruned)
71
+ save_state(messages: pruned, **preserved_flush_state)
71
72
  true
72
73
  end
73
74
 
@@ -90,14 +91,16 @@ module Llmemory
90
91
  current_bytes = messages_byte_size(msgs)
91
92
  return false if current_bytes <= max
92
93
 
93
- flush_memory_before_compaction!(msgs)
94
+ flushed = flush_memory_before_compaction!(msgs)
94
95
 
95
96
  old_msgs, recent_msgs = split_messages_by_bytes(msgs, max)
96
97
  return false if old_msgs.empty?
97
98
 
98
99
  summary = summarize_messages(old_msgs)
99
100
  compacted = [{ role: :system, content: summary }] + recent_msgs
100
- save_state(messages: compacted)
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)
101
104
  true
102
105
  end
103
106
 
@@ -126,6 +129,25 @@ module Llmemory
126
129
  ctx >= threshold
127
130
  end
128
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
+
129
151
  def check_context_window!
130
152
  return false if messages.empty?
131
153
 
@@ -169,11 +191,40 @@ module Llmemory
169
191
  end
170
192
 
171
193
  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
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
175
206
 
176
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
177
228
  end
178
229
 
179
230
  def estimated_tokens(msgs)
@@ -225,8 +276,10 @@ module Llmemory
225
276
  end
226
277
  end
227
278
 
228
- def save_state(messages:)
279
+ def save_state(messages:, last_flush_at: nil, last_compact_at: nil)
229
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
230
283
  @checkpoint.save_state(state)
231
284
  end
232
285
 
@@ -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
@@ -53,7 +53,8 @@ module Llmemory
53
53
  {
54
54
  text: c[:text] || c["text"],
55
55
  timestamp: parse_timestamp(c[:timestamp] || c["timestamp"] || c[:created_at] || c["created_at"]),
56
- 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"]
57
58
  }
58
59
  end
59
60
  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
- time_decay = 1.0 / (1.0 + (age_days.to_f / @half_life_days))
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "short_term/checkpoint"
4
4
  require_relative "short_term/session_lifecycle"
5
+ require_relative "short_term/message_sanitizer"
5
6
 
6
7
  module Llmemory
7
8
  module ShortTerm
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "digest"
5
6
  require_relative "base"
6
7
 
7
8
  module Llmemory
@@ -13,10 +14,46 @@ module Llmemory
13
14
  def initialize(api_key: nil, model: nil)
14
15
  @api_key = api_key || Llmemory.configuration.llm_api_key
15
16
  @model = model || DEFAULT_MODEL
17
+ @cache = {}
18
+ @cache_order = []
16
19
  end
17
20
 
18
21
  def embed(text)
19
22
  return Array.new(DEFAULT_DIMS, 0.0) if text.to_s.strip.empty?
23
+
24
+ if Llmemory.configuration.embedding_cache_enabled
25
+ key = cache_key(text)
26
+ return @cache[key].dup if @cache.key?(key)
27
+ end
28
+
29
+ result = fetch_embedding(text)
30
+
31
+ if Llmemory.configuration.embedding_cache_enabled
32
+ evict_if_needed
33
+ @cache[cache_key(text)] = result.dup
34
+ @cache_order << cache_key(text)
35
+ end
36
+
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def cache_key(text)
43
+ Digest::SHA256.hexdigest("#{@model}:#{text.to_s.strip}")
44
+ end
45
+
46
+ def evict_if_needed
47
+ max = Llmemory.configuration.embedding_cache_max_entries.to_i
48
+ return if max <= 0 || @cache.size < max
49
+
50
+ while @cache_order.any? && @cache.size >= max
51
+ k = @cache_order.shift
52
+ @cache.delete(k)
53
+ end
54
+ end
55
+
56
+ def fetch_embedding(text)
20
57
  response = connection.post("embeddings") do |req|
21
58
  req.headers["Authorization"] = "Bearer #{@api_key}"
22
59
  req.headers["Content-Type"] = "application/json"
@@ -27,16 +64,6 @@ module Llmemory
27
64
  body.dig("data", 0, "embedding")&.map(&:to_f) || Array.new(DEFAULT_DIMS, 0.0)
28
65
  end
29
66
 
30
- def store(id:, embedding:, metadata: {})
31
- raise NotImplementedError, "OpenAIEmbeddings does not store; use a VectorStore backend (e.g. MemoryStore)"
32
- end
33
-
34
- def search(query_embedding, top_k: 10)
35
- raise NotImplementedError, "OpenAIEmbeddings does not search; use a VectorStore backend"
36
- end
37
-
38
- private
39
-
40
67
  def connection
41
68
  @connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
42
69
  f.request :json
@@ -44,6 +71,14 @@ module Llmemory
44
71
  f.adapter Faraday.default_adapter
45
72
  end
46
73
  end
74
+
75
+ def store(id:, embedding:, metadata: {})
76
+ raise NotImplementedError, "OpenAIEmbeddings does not store; use a VectorStore backend (e.g. MemoryStore)"
77
+ end
78
+
79
+ def search(query_embedding, top_k: 10)
80
+ raise NotImplementedError, "OpenAIEmbeddings does not search; use a VectorStore backend"
81
+ end
47
82
  end
48
83
  end
49
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.1.14"
4
+ VERSION = "0.1.15"
5
5
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :release do
4
+ desc "Bump version (patch|minor|major). Checks: branch=main, no uncommitted changes, tests pass. Then: Gemfile.lock, CHANGELOG, commit, push, tag"
5
+ task :bump, [:bump_type] => [] do |_t, args|
6
+ require_relative "../llmemory/version"
7
+
8
+ # Pre-flight checks
9
+ current_branch = `git rev-parse --abbrev-ref HEAD`.strip
10
+ abort "Current branch must be main (got: #{current_branch})" unless current_branch == "main"
11
+
12
+ # Allow only release-related files to be modified (we'll commit them)
13
+ release_files = %w[lib/llmemory/version.rb Gemfile.lock CHANGELOG.txt]
14
+ status_lines = `git status --porcelain`.strip.lines
15
+ other_changes = status_lines.reject do |line|
16
+ path = line.sub(/\A..\s+/, "").strip
17
+ release_files.include?(path)
18
+ end
19
+ abort "Working tree has uncommitted changes outside release files. Commit or stash them first." unless other_changes.empty?
20
+
21
+ puts "Running tests..."
22
+ sh "bundle exec rspec"
23
+ puts "Tests passed.\n\n"
24
+
25
+ bump_type = (args[:bump_type] || "patch").to_s.downcase
26
+ unless %w[patch minor major].include?(bump_type)
27
+ abort "Bump type must be: patch, minor, or major"
28
+ end
29
+
30
+ seg = Gem::Version.new(Llmemory::VERSION).segments
31
+ new_version = case bump_type
32
+ when "patch" then Gem::Version.new("#{seg[0]}.#{seg[1] || 0}.#{(seg[2] || 0) + 1}")
33
+ when "minor" then Gem::Version.new("#{seg[0]}.#{(seg[1] || 0) + 1}.0")
34
+ when "major" then Gem::Version.new("#{(seg[0] || 0) + 1}.0.0")
35
+ end
36
+ new_version_s = new_version.to_s
37
+
38
+ puts "Bumping #{Llmemory::VERSION} -> #{new_version_s} (#{bump_type})"
39
+
40
+ # 1. Update version.rb
41
+ version_file = File.expand_path("../llmemory/version.rb", __dir__)
42
+ content = File.read(version_file)
43
+ content = content.sub(/VERSION = "[^"]+"/, %(VERSION = "#{new_version_s}"))
44
+ File.write(version_file, content)
45
+ puts " Updated lib/llmemory/version.rb"
46
+
47
+ # 2. bundle install
48
+ sh "bundle install"
49
+ puts " Updated Gemfile.lock"
50
+
51
+ # 3. Update CHANGELOG.txt
52
+ changelog_path = File.expand_path("../../CHANGELOG.txt", __dir__)
53
+ changelog_content = if File.exist?(changelog_path)
54
+ File.read(changelog_path)
55
+ else
56
+ ""
57
+ end
58
+
59
+ today = Time.now.strftime("%Y-%m-%d")
60
+ last_tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
61
+ commits = if last_tag.empty?
62
+ `git log --oneline`.strip
63
+ else
64
+ `git log #{last_tag}..HEAD --oneline`.strip
65
+ end
66
+
67
+ new_entry = <<~CHANGELOG
68
+
69
+ ## [#{new_version_s}] - #{today}
70
+
71
+ ### Changes
72
+ #{commits.lines.map { |l| "- #{l.strip}" }.join("\n")}
73
+ CHANGELOG
74
+
75
+ header = "# Changelog\n\n"
76
+ if changelog_content.empty?
77
+ changelog_content = header + new_entry
78
+ else
79
+ changelog_content = header + changelog_content unless changelog_content.start_with?(header)
80
+ changelog_content = changelog_content.sub(/(# Changelog\n\n)/m, "\\1#{new_entry.lstrip}")
81
+ end
82
+ File.write(changelog_path, changelog_content)
83
+ puts " Updated CHANGELOG.txt"
84
+
85
+ # 4. Commit
86
+ sh "git add lib/llmemory/version.rb Gemfile.lock CHANGELOG.txt"
87
+ sh "git commit -m 'Release v#{new_version_s}'"
88
+
89
+ # 5. Push
90
+ sh "git push"
91
+
92
+ # 6. Tag
93
+ sh "git tag v#{new_version_s}"
94
+
95
+ # 7. Push tag
96
+ sh "git push origin v#{new_version_s}"
97
+
98
+ puts "\nDone. Released v#{new_version_s}"
99
+ end
100
+ 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.14
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - llmemory
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.6'
40
+ - !ruby/object:Gem::Dependency
41
+ name: simplecov
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.22'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.22'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rspec
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -180,6 +194,7 @@ files:
180
194
  - lib/llmemory/mcp/tools/memory_timeline.rb
181
195
  - lib/llmemory/mcp/tools/memory_timeline_context.rb
182
196
  - lib/llmemory/memory.rb
197
+ - lib/llmemory/noise_filter.rb
183
198
  - lib/llmemory/retrieval.rb
184
199
  - lib/llmemory/retrieval/bm25_scorer.rb
185
200
  - lib/llmemory/retrieval/context_assembler.rb
@@ -188,6 +203,7 @@ files:
188
203
  - lib/llmemory/retrieval/temporal_ranker.rb
189
204
  - lib/llmemory/short_term.rb
190
205
  - lib/llmemory/short_term/checkpoint.rb
206
+ - lib/llmemory/short_term/message_sanitizer.rb
191
207
  - lib/llmemory/short_term/pruner.rb
192
208
  - lib/llmemory/short_term/session_lifecycle.rb
193
209
  - lib/llmemory/short_term/stores/active_record_checkpoint.rb
@@ -203,6 +219,7 @@ files:
203
219
  - lib/llmemory/vector_store/memory_store.rb
204
220
  - lib/llmemory/vector_store/openai_embeddings.rb
205
221
  - lib/llmemory/version.rb
222
+ - lib/tasks/release.rake
206
223
  homepage: https://github.com/entaina/llmemory
207
224
  licenses:
208
225
  - MIT