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 +4 -4
- data/README.md +104 -3
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +1 -0
- data/lib/llmemory/configuration.rb +17 -1
- data/lib/llmemory/extractors/fact_extractor.rb +9 -2
- data/lib/llmemory/long_term/file_based/memory.rb +35 -7
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +12 -6
- data/lib/llmemory/long_term/file_based/storages/base.rb +1 -1
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +11 -8
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -1
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -1
- data/lib/llmemory/long_term/graph_based/memory.rb +5 -1
- data/lib/llmemory/memory.rb +62 -9
- data/lib/llmemory/noise_filter.rb +36 -0
- data/lib/llmemory/retrieval/engine.rb +2 -1
- data/lib/llmemory/retrieval/temporal_ranker.rb +9 -1
- data/lib/llmemory/short_term/message_sanitizer.rb +43 -0
- data/lib/llmemory/short_term.rb +1 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +45 -10
- data/lib/llmemory/version.rb +1 -1
- data/lib/tasks/release.rake +100 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 723fae20d0310ccaeaf9ba600148061d17b2a0b29f933d455d1cf656dee85636
|
|
4
|
+
data.tar.gz: a135ea1661af46e96843bf52744e8004d0ebe7e8d94b0c46a097c36df53d5bc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 |
|
|
@@ -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"
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"])
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/llmemory/short_term.rb
CHANGED
|
@@ -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
|
data/lib/llmemory/version.rb
CHANGED
|
@@ -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.
|
|
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
|