llmemory 0.1.14 → 0.1.16

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: 135c27c05f80b660972a5aa8df5ba315cbdd8235ac68c662ddcfe9249ca79109
4
+ data.tar.gz: 2e43bacc332ddb44613c79ebd7e4e832091fc68c451a75073860502962d496d9
5
5
  SHA512:
6
- metadata.gz: 72337abbc9bd02e9289a9b4dad399741f066fc9dbaf01cd2fa8de80813f4042c8939313afb7aa248addf02ade1cdae379a93585618200f96c3e2d0dbafef9138
7
- data.tar.gz: 263872146fb6654ecfef977b7a7075798b6439e06405eeb512ff48b67c7294387eb963421cbffed1fecf0e4863f7bfc82b0d6f88f3e92eb8cc06898d587c50b2
6
+ metadata.gz: 9b0d7d67c2647ec0392f993ed31aedc5d51fdc3dadf40bd1a511b90a55b24ce415a9b9a420cc6e676d5a3ba8eb780048e047eff9d0800618dc10e99c4c779a1f
7
+ data.tar.gz: 10184e66c86c94976d2c164f8717633fbb438e0e95cfb0dbffed4ce52f8bbf8c4266c0b3e48879c792250da15a385464983a886408c69598044c0f60c13f27e7
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,8 @@ 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
20
+ t.jsonb :provenance
19
21
  t.timestamps
20
22
  end
21
23
  add_index :llmemory_items, :user_id
@@ -14,6 +14,7 @@ module Llmemory
14
14
  :database_url,
15
15
  :vector_store,
16
16
  :time_decay_half_life_days,
17
+ :importance_weight,
17
18
  :max_retrieval_tokens,
18
19
  :prune_after_days,
19
20
  :compact_max_bytes,
@@ -33,7 +34,15 @@ module Llmemory
33
34
  :session_prune_after_days,
34
35
  :session_max_entries_per_user,
35
36
  :daily_logs_enabled,
36
- :auto_recall_enabled
37
+ :auto_recall_enabled,
38
+ :noise_filter_enabled,
39
+ :noise_filter_min_chars,
40
+ :flush_once_per_cycle_seconds,
41
+ :overflow_recovery_enabled,
42
+ :embedding_cache_enabled,
43
+ :embedding_cache_max_entries,
44
+ :max_message_chars,
45
+ :message_sanitizer_enabled
37
46
 
38
47
  def initialize
39
48
  @llm_provider = :openai
@@ -48,6 +57,7 @@ module Llmemory
48
57
  @database_url = ENV["DATABASE_URL"]
49
58
  @vector_store = nil
50
59
  @time_decay_half_life_days = 30
60
+ @importance_weight = 1.0
51
61
  @max_retrieval_tokens = 2000
52
62
  @prune_after_days = 90
53
63
  @compact_max_bytes = 8192
@@ -68,6 +78,14 @@ module Llmemory
68
78
  @session_max_entries_per_user = 500
69
79
  @daily_logs_enabled = false
70
80
  @auto_recall_enabled = false
81
+ @noise_filter_enabled = false
82
+ @noise_filter_min_chars = 10
83
+ @flush_once_per_cycle_seconds = 60
84
+ @overflow_recovery_enabled = false
85
+ @embedding_cache_enabled = true
86
+ @embedding_cache_max_entries = 10_000
87
+ @max_message_chars = 32_000
88
+ @message_sanitizer_enabled = false
71
89
  end
72
90
  end
73
91
 
@@ -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,14 +4,15 @@ module Llmemory
4
4
  module LongTerm
5
5
  module FileBased
6
6
  class Item
7
- attr_reader :id, :user_id, :category, :content, :source_resource_id, :created_at
7
+ attr_reader :id, :user_id, :category, :content, :source_resource_id, :provenance, :created_at
8
8
 
9
- def initialize(id:, user_id:, category:, content:, source_resource_id: nil, created_at: nil)
9
+ def initialize(id:, user_id:, category:, content:, source_resource_id: nil, provenance: nil, created_at: nil)
10
10
  @id = id
11
11
  @user_id = user_id
12
12
  @category = category
13
13
  @content = content
14
14
  @source_resource_id = source_resource_id
15
+ @provenance = provenance
15
16
  @created_at = created_at || Time.now
16
17
  end
17
18
 
@@ -22,6 +23,7 @@ module Llmemory
22
23
  category: category,
23
24
  content: content,
24
25
  source_resource_id: source_resource_id,
26
+ provenance: provenance,
25
27
  created_at: created_at.iso8601
26
28
  }
27
29
  end
@@ -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,20 @@ 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: 1.0,
69
+ importance: (i[:importance] || i["importance"] || 1.0).to_f,
70
+ evergreen: i[:evergreen] || i["evergreen"]
58
71
  }
59
72
  end
60
73
  resources.first([top_k - out.size, 0].max).each do |r|
@@ -80,9 +93,12 @@ module Llmemory
80
93
  @storage.save_resource(@user_id, text)
81
94
  end
82
95
 
83
- def save_item(category:, item:, source_resource_id:)
96
+ def save_item(category:, item:, source_resource_id:, importance: 0.7)
84
97
  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)
98
+ provenance = Llmemory::Provenance.from_resource(
99
+ source_resource_id, method: "fact_extraction", confidence: importance
100
+ )
101
+ @storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id, importance: importance, provenance: provenance)
86
102
  end
87
103
 
88
104
  def append_to_daily_log(conversation_text)
@@ -96,6 +112,22 @@ module Llmemory
96
112
  logs = @storage.load_daily_logs(user_id, from_date: yesterday, to_date: today)
97
113
  logs.map { |l| { date: l[:date], content: "[#{l[:date]}] #{l[:content]}" } }
98
114
  end
115
+
116
+ def load_category_summaries_as_candidates(user_id, query)
117
+ return [] unless @storage.respond_to?(:list_categories)
118
+
119
+ categories = @storage.list_categories(user_id)
120
+ return [] if categories.empty?
121
+
122
+ query_lower = query.to_s.downcase
123
+ categories.filter_map do |cat|
124
+ summary = @storage.load_category(user_id, cat)
125
+ next if summary.to_s.strip.empty?
126
+ next unless summary.to_s.downcase.include?(query_lower)
127
+
128
+ { text: "[#{cat}] #{summary}", timestamp: Time.now, score: 0.95 }
129
+ end
130
+ end
99
131
  end
100
132
  end
101
133
  end
@@ -30,16 +30,19 @@ 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, provenance: nil)
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
+ attrs[:provenance] = provenance if provenance && LlmemoryItem.column_names.include?("provenance")
45
+ LlmemoryItem.create!(attrs)
43
46
  id
44
47
  end
45
48
 
@@ -96,14 +99,16 @@ module Llmemory
96
99
  def replace_items(user_id, ids_to_remove, merged_item)
97
100
  LlmemoryItem.where(user_id: user_id, id: ids_to_remove).destroy_all
98
101
  created_at = merged_item[:created_at] || Time.current
99
- LlmemoryItem.create!(
102
+ attrs = {
100
103
  id: "item_#{SecureRandom.hex(8)}",
101
104
  user_id: user_id,
102
105
  category: merged_item[:category],
103
106
  content: merged_item[:content],
104
107
  source_resource_id: merged_item[:source_resource_id],
105
108
  created_at: created_at
106
- )
109
+ }
110
+ attrs[:importance] = merged_item[:importance] if LlmemoryItem.column_names.include?("importance") && merged_item[:importance]
111
+ LlmemoryItem.create!(attrs)
107
112
  end
108
113
 
109
114
  def archive_items(user_id, item_ids)
@@ -177,13 +182,16 @@ module Llmemory
177
182
  end
178
183
 
179
184
  def row_to_item(r)
180
- {
185
+ h = {
181
186
  id: r.id,
182
187
  category: r.category,
183
188
  content: r.content,
184
189
  source_resource_id: r.source_resource_id,
185
190
  created_at: r.created_at
186
191
  }
192
+ h[:importance] = r.respond_to?(:importance) ? (r.importance || 0.7).to_f : 0.7
193
+ h[:provenance] = r.provenance if r.respond_to?(:provenance)
194
+ h
187
195
  end
188
196
 
189
197
  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, provenance: nil)
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, provenance: nil)
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, provenance, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)",
32
+ [id, user_id, category, content, source_resource_id, importance.to_f, provenance ? JSON.generate(provenance) : nil, 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, provenance, 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, provenance, 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, provenance, 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, provenance, 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, provenance, 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,14 @@ 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,
261
+ provenance JSONB,
260
262
  created_at TIMESTAMPTZ NOT NULL
261
263
  );
262
264
  CREATE INDEX IF NOT EXISTS idx_llmemory_items_user_id ON llmemory_items(user_id);
263
265
  SQL
266
+ conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS importance REAL DEFAULT 0.7") rescue nil
267
+ conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS provenance JSONB") rescue nil
264
268
  conn.exec(<<~SQL)
265
269
  CREATE TABLE IF NOT EXISTS llmemory_categories (
266
270
  user_id TEXT NOT NULL,
@@ -279,11 +283,20 @@ module Llmemory
279
283
  category: r["category"],
280
284
  content: r["content"],
281
285
  source_resource_id: r["source_resource_id"],
286
+ importance: (r["importance"] || 0.7).to_f,
287
+ provenance: parse_provenance(r["provenance"]),
282
288
  created_at: Time.parse(r["created_at"])
283
289
  }
284
290
  end
285
291
  end
286
292
 
293
+ def parse_provenance(value)
294
+ return nil if value.nil? || value.to_s.strip.empty?
295
+ JSON.parse(value, symbolize_names: true)
296
+ rescue JSON::ParserError
297
+ nil
298
+ end
299
+
287
300
  def rows_to_resources(rows)
288
301
  rows.map do |r|
289
302
  {
@@ -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, provenance: nil)
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,8 @@ module Llmemory
34
34
  category: category,
35
35
  content: content,
36
36
  source_resource_id: source_resource_id,
37
+ importance: importance,
38
+ provenance: provenance,
37
39
  created_at: Time.now.iso8601
38
40
  }
39
41
  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, provenance: nil)
26
26
  @item_id_seq += 1
27
27
  id = "item_#{@item_id_seq}"
28
28
  @items[user_id] << {
@@ -30,6 +30,8 @@ module Llmemory
30
30
  category: category,
31
31
  content: content,
32
32
  source_resource_id: source_resource_id,
33
+ importance: importance,
34
+ provenance: provenance,
33
35
  created_at: Time.now
34
36
  }
35
37
  id
@@ -31,6 +31,13 @@ module Llmemory
31
31
  !archived_at.nil?
32
32
  end
33
33
 
34
+ # Lineage of this edge, stored within properties so it round-trips
35
+ # through every backend without a schema change. See Llmemory::Provenance.
36
+ def provenance
37
+ props = properties || {}
38
+ props[:provenance] || props["provenance"]
39
+ end
40
+
34
41
  def to_h
35
42
  {
36
43
  id: 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,13 +22,17 @@ 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"])
28
32
 
29
33
  return true if entities.empty? && relations.empty?
30
34
 
35
+ provenance = Llmemory::Provenance.from_text_fingerprint(text, method: "entity_relation_extraction")
31
36
  name_to_id = {}
32
37
 
33
38
  entities.each do |e|
@@ -35,7 +40,7 @@ module Llmemory
35
40
  entity_type = e[:type] || e["type"] || "concept"
36
41
  name = e[:name] || e["name"]
37
42
  next if name.nil? || name.to_s.strip.empty?
38
- id = @kg.add_node(entity_type: entity_type, name: name.to_s.strip, properties: {})
43
+ id = @kg.add_node(entity_type: entity_type, name: name.to_s.strip, properties: { "provenance" => provenance })
39
44
  name_to_id[name.to_s.strip] ||= id
40
45
  end
41
46
 
@@ -46,8 +51,8 @@ module Llmemory
46
51
  object = (r[:object] || r["object"]).to_s.strip
47
52
  next if subject.empty? || predicate.empty? || object.empty?
48
53
 
49
- subject_id = name_to_id[subject] || @kg.add_node(entity_type: "concept", name: subject, properties: {})
50
- object_id = name_to_id[object] || @kg.add_node(entity_type: "concept", name: object, properties: {})
54
+ subject_id = name_to_id[subject] || @kg.add_node(entity_type: "concept", name: subject, properties: { "provenance" => provenance })
55
+ object_id = name_to_id[object] || @kg.add_node(entity_type: "concept", name: object, properties: { "provenance" => provenance })
51
56
 
52
57
  edge = Edge.new(
53
58
  id: nil,
@@ -55,12 +60,12 @@ module Llmemory
55
60
  subject_id: subject_id,
56
61
  predicate: predicate,
57
62
  target_id: object_id,
58
- properties: {},
63
+ properties: { "provenance" => provenance },
59
64
  created_at: Time.now,
60
65
  archived_at: nil
61
66
  )
62
67
  @conflict_resolver.resolve(edge)
63
- edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: {})
68
+ edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: { "provenance" => provenance })
64
69
 
65
70
  text = "#{subject} #{predicate} #{object}"
66
71
  embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(text) : nil
@@ -85,7 +90,8 @@ module Llmemory
85
90
  {
86
91
  text: r[:text],
87
92
  timestamp: r[:created_at] || r[:timestamp],
88
- score: r[:score] || 1.0
93
+ score: r[:score] || 1.0,
94
+ importance: r[:importance]
89
95
  }
90
96
  end
91
97
  end
@@ -25,6 +25,13 @@ module Llmemory
25
25
  )
26
26
  end
27
27
 
28
+ # Lineage of this node, stored within properties so it round-trips
29
+ # through every backend without a schema change. See Llmemory::Provenance.
30
+ def provenance
31
+ props = properties || {}
32
+ props[:provenance] || props["provenance"]
33
+ end
34
+
28
35
  def to_h
29
36
  {
30
37
  id: id,
@@ -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
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Llmemory
6
+ # Provenance records the lineage of a long-term memory item: where it came
7
+ # from, how it was produced, and with what confidence. It is stored as a
8
+ # plain JSON-safe Hash so it round-trips through every storage backend
9
+ # (in-memory, JSON files, SQL columns, jsonb properties) without coupling.
10
+ #
11
+ # Shape: { sources: [{ type:, id: }], method:, confidence:, created_at: }
12
+ #
13
+ # `method` identifies the producing process (e.g. "fact_extraction",
14
+ # "entity_relation_extraction", and in the future "reflection"), so a
15
+ # semantic datum can always be traced back to its raw source.
16
+ module Provenance
17
+ module_function
18
+
19
+ def build(method:, sources: [], confidence: nil, created_at: nil)
20
+ {
21
+ sources: Array(sources).filter_map { |s| normalize_source(s) },
22
+ method: method&.to_s,
23
+ confidence: confidence.nil? ? nil : confidence.to_f,
24
+ created_at: normalize_time(created_at)
25
+ }
26
+ end
27
+
28
+ # Convenience for the file-based path, where the raw text is persisted as a
29
+ # Resource and referenced by id.
30
+ def from_resource(resource_id, method:, confidence: nil, created_at: nil)
31
+ sources = resource_id ? [{ type: "resource", id: resource_id }] : []
32
+ build(method: method, sources: sources, confidence: confidence, created_at: created_at)
33
+ end
34
+
35
+ # Convenience for the graph-based path, which does not persist the raw text.
36
+ # We record a stable fingerprint of the source instead of the document
37
+ # itself, keeping lineage verifiable without exposing sensitive content.
38
+ def from_text_fingerprint(text, method:, confidence: nil, created_at: nil)
39
+ require "digest"
40
+ sources = []
41
+ unless text.to_s.strip.empty?
42
+ sources = [{ type: "text_sha256", id: Digest::SHA256.hexdigest(text.to_s)[0, 16] }]
43
+ end
44
+ build(method: method, sources: sources, confidence: confidence, created_at: created_at)
45
+ end
46
+
47
+ def normalize_source(source)
48
+ return nil if source.nil?
49
+ if source.is_a?(Hash)
50
+ type = source[:type] || source["type"]
51
+ id = source[:id] || source["id"]
52
+ return nil if id.nil?
53
+ { type: type.nil? ? "unknown" : type.to_s, id: id }
54
+ else
55
+ { type: "unknown", id: source }
56
+ end
57
+ end
58
+
59
+ def normalize_time(value)
60
+ value ||= Time.now
61
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
62
+ end
63
+ end
64
+ end
@@ -53,7 +53,9 @@ 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
+ importance: c[:importance] || c["importance"],
58
+ evergreen: c[:evergreen] || c["evergreen"]
57
59
  }
58
60
  end
59
61
  end
@@ -3,21 +3,43 @@
3
3
  module Llmemory
4
4
  module Retrieval
5
5
  class TemporalRanker
6
- def initialize(half_life_days: nil)
6
+ def initialize(half_life_days: nil, importance_weight: nil)
7
7
  @half_life_days = half_life_days || Llmemory.configuration.time_decay_half_life_days
8
+ @importance_weight = importance_weight || Llmemory.configuration.importance_weight
8
9
  end
9
10
 
10
11
  def rank(candidates, now: Time.now)
12
+ lambda_val = Math.log(2) / @half_life_days.to_f
13
+ weight = [@importance_weight.to_f, 0.0].max
14
+
11
15
  candidates.map do |c|
12
16
  score = (c[:score] || c["score"] || 1.0).to_f
13
17
  timestamp = c[:timestamp] || c["timestamp"]
14
18
  timestamp = Time.parse(timestamp.to_s) if timestamp.is_a?(String)
15
19
  age_days = timestamp ? (now - timestamp).to_i / 86400 : 0
16
- time_decay = 1.0 / (1.0 + (age_days.to_f / @half_life_days))
17
- final_score = score * time_decay
18
- c.merge(score: score, temporal_score: final_score, timestamp: timestamp)
20
+
21
+ time_decay = if c[:evergreen] || c["evergreen"]
22
+ 1.0
23
+ else
24
+ Math.exp(-lambda_val * age_days.to_f)
25
+ end
26
+
27
+ importance = normalize_importance(c[:importance] || c["importance"])
28
+ importance_factor = importance**weight
29
+
30
+ final_score = score * time_decay * importance_factor
31
+ c.merge(score: score, importance: importance, temporal_score: final_score, timestamp: timestamp)
19
32
  end.sort_by { |c| -(c[:temporal_score] || 0) }
20
33
  end
34
+
35
+ private
36
+
37
+ # Missing importance is neutral (1.0) so candidates that carry no
38
+ # importance signal (resources, graph edges) are never penalised.
39
+ def normalize_importance(value)
40
+ return 1.0 if value.nil?
41
+ [[value.to_f, 0.0].max, 1.0].min
42
+ end
21
43
  end
22
44
  end
23
45
  end
@@ -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.16"
5
5
  end
data/lib/llmemory.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "llmemory/version"
4
4
  require_relative "llmemory/configuration"
5
+ require_relative "llmemory/provenance"
5
6
  require_relative "llmemory/llm"
6
7
  require_relative "llmemory/short_term"
7
8
  require_relative "llmemory/long_term"
@@ -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.16
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,8 @@ 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
198
+ - lib/llmemory/provenance.rb
183
199
  - lib/llmemory/retrieval.rb
184
200
  - lib/llmemory/retrieval/bm25_scorer.rb
185
201
  - lib/llmemory/retrieval/context_assembler.rb
@@ -188,6 +204,7 @@ files:
188
204
  - lib/llmemory/retrieval/temporal_ranker.rb
189
205
  - lib/llmemory/short_term.rb
190
206
  - lib/llmemory/short_term/checkpoint.rb
207
+ - lib/llmemory/short_term/message_sanitizer.rb
191
208
  - lib/llmemory/short_term/pruner.rb
192
209
  - lib/llmemory/short_term/session_lifecycle.rb
193
210
  - lib/llmemory/short_term/stores/active_record_checkpoint.rb
@@ -203,6 +220,7 @@ files:
203
220
  - lib/llmemory/vector_store/memory_store.rb
204
221
  - lib/llmemory/vector_store/openai_embeddings.rb
205
222
  - lib/llmemory/version.rb
223
+ - lib/tasks/release.rake
206
224
  homepage: https://github.com/entaina/llmemory
207
225
  licenses:
208
226
  - MIT
@@ -223,7 +241,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
241
  - !ruby/object:Gem::Version
224
242
  version: '0'
225
243
  requirements: []
226
- rubygems_version: 4.0.3
244
+ rubygems_version: 4.0.10
227
245
  specification_version: 4
228
246
  summary: Persistent memory system for LLM agents
229
247
  test_files: []