llmemory 0.2.6 → 0.2.7
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 +22 -5
- data/app/controllers/llmemory/dashboard/application_controller.rb +12 -0
- data/app/views/llmemory/dashboard/short_term/show.html.erb +1 -1
- data/lib/llmemory/active_record_helpers.rb +21 -0
- data/lib/llmemory/cli/commands/mcp.rb +5 -3
- data/lib/llmemory/configuration.rb +41 -6
- data/lib/llmemory/crypto/field_helpers.rb +5 -0
- data/lib/llmemory/extractors/entity_relation_extractor.rb +9 -2
- data/lib/llmemory/extractors/fact_extractor.rb +67 -1
- data/lib/llmemory/forget_log.rb +7 -3
- data/lib/llmemory/llm/anthropic.rb +4 -6
- data/lib/llmemory/llm/http_client.rb +55 -0
- data/lib/llmemory/llm/openai.rb +5 -7
- data/lib/llmemory/llm/tracking_client.rb +22 -12
- data/lib/llmemory/llm/usage.rb +4 -0
- data/lib/llmemory/llm/usage_ledger.rb +25 -24
- data/lib/llmemory/long_term/episodic/memory.rb +5 -2
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/long_term/file_based/memory.rb +9 -4
- data/lib/llmemory/long_term/file_based/storage.rb +15 -2
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/long_term/graph_based/memory.rb +39 -5
- data/lib/llmemory/long_term/graph_based/storage.rb +15 -2
- data/lib/llmemory/long_term/procedural/memory.rb +5 -2
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/maintenance/consolidator.rb +6 -2
- data/lib/llmemory/mcp/authentication.rb +5 -3
- data/lib/llmemory/mcp/server.rb +21 -6
- data/lib/llmemory/mcp/store_helpers.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_save.rb +15 -18
- data/lib/llmemory/mcp/tools/memory_search.rb +16 -19
- data/lib/llmemory/memory.rb +29 -11
- data/lib/llmemory/memory_module.rb +6 -2
- data/lib/llmemory/provenance.rb +23 -1
- data/lib/llmemory/retrieval/feedback_store.rb +11 -5
- data/lib/llmemory/retrieval/multi_source.rb +26 -0
- data/lib/llmemory/retrieval/temporal_ranker.rb +4 -2
- data/lib/llmemory/retrieval.rb +1 -0
- data/lib/llmemory/short_term/pruner.rb +16 -4
- data/lib/llmemory/short_term/session_lifecycle.rb +18 -9
- data/lib/llmemory/short_term/stores/active_record_store.rb +37 -8
- data/lib/llmemory/short_term/stores/base.rb +9 -0
- data/lib/llmemory/short_term/stores/key_codec.rb +33 -0
- data/lib/llmemory/short_term/stores/memory_store.rb +38 -7
- data/lib/llmemory/short_term/stores/postgres_store.rb +81 -33
- data/lib/llmemory/short_term/stores/redis_store.rb +85 -9
- data/lib/llmemory/short_term/stores.rb +17 -2
- data/lib/llmemory/vector_store/active_record_store.rb +23 -8
- data/lib/llmemory/vector_store/base.rb +4 -0
- data/lib/llmemory/vector_store/memory_store.rb +9 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +6 -7
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +10 -7
- metadata +6 -15
|
@@ -21,9 +21,10 @@ module Llmemory
|
|
|
21
21
|
|
|
22
22
|
attr_reader :user_id, :storage
|
|
23
23
|
|
|
24
|
-
def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil)
|
|
24
|
+
def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil, forget_log_store: nil)
|
|
25
25
|
@user_id = user_id
|
|
26
26
|
@cipher = cipher || Llmemory.build_cipher
|
|
27
|
+
@forget_log_store = forget_log_store
|
|
27
28
|
@storage = storage || Storages.build(cipher: @cipher)
|
|
28
29
|
@vector_store = vector_store
|
|
29
30
|
@vector_explicit = !vector_store.nil?
|
|
@@ -71,11 +72,13 @@ module Llmemory
|
|
|
71
72
|
# recency (temporal decay) and importance (P3), with provenance (P10).
|
|
72
73
|
# Hybrid (vector + keyword) when a vector store is active; otherwise
|
|
73
74
|
# keyword-only.
|
|
75
|
+
KEYWORD_MATCH_SCORE = 0.6
|
|
76
|
+
|
|
74
77
|
def search_candidates(query, user_id: nil, top_k: 20)
|
|
75
78
|
uid = user_id || @user_id
|
|
76
79
|
return [] unless uid == @user_id
|
|
77
80
|
|
|
78
|
-
keyword = @storage.search_episodes(uid, query).first(top_k).map { |e| candidate_for(e,
|
|
81
|
+
keyword = @storage.search_episodes(uid, query).first(top_k).map { |e| candidate_for(e, KEYWORD_MATCH_SCORE) }
|
|
79
82
|
vs = vector_store
|
|
80
83
|
return keyword unless vs
|
|
81
84
|
|
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
7
|
require_relative "../../../crypto/field_helpers"
|
|
8
|
+
require_relative "../../../active_record_helpers"
|
|
8
9
|
|
|
9
10
|
module Llmemory
|
|
10
11
|
module LongTerm
|
|
@@ -15,6 +16,7 @@ module Llmemory
|
|
|
15
16
|
# handles. Mirrors the file-based ActiveRecordStorage pattern.
|
|
16
17
|
class ActiveRecordStorage < Base
|
|
17
18
|
include Llmemory::Crypto::FieldHelpers
|
|
19
|
+
include Llmemory::ActiveRecordHelpers
|
|
18
20
|
|
|
19
21
|
def initialize(cipher: nil)
|
|
20
22
|
@cipher = cipher || Llmemory.build_cipher
|
|
@@ -39,7 +41,7 @@ module Llmemory
|
|
|
39
41
|
rec.search_text = enc(text)
|
|
40
42
|
rec.search_tokens = search_tokens_for(text) if LlmemoryEpisode.column_names.include?("search_tokens")
|
|
41
43
|
rec.created_at ||= Time.current
|
|
42
|
-
rec.save!
|
|
44
|
+
with_unique_retry { rec.save! }
|
|
43
45
|
id
|
|
44
46
|
end
|
|
45
47
|
|
|
@@ -13,9 +13,10 @@ module Llmemory
|
|
|
13
13
|
class Memory
|
|
14
14
|
include Llmemory::MemoryModule
|
|
15
15
|
|
|
16
|
-
def initialize(user_id:, storage: nil, llm: nil, extractor: nil)
|
|
16
|
+
def initialize(user_id:, storage: nil, llm: nil, extractor: nil, forget_log_store: nil)
|
|
17
17
|
@user_id = user_id
|
|
18
18
|
@storage = storage || Storages.build
|
|
19
|
+
@forget_log_store = forget_log_store
|
|
19
20
|
@llm = llm || Llmemory::LLM.client
|
|
20
21
|
@extractor = extractor || Llmemory::Extractors::FactExtractor.new(llm: @llm)
|
|
21
22
|
end
|
|
@@ -27,12 +28,16 @@ module Llmemory
|
|
|
27
28
|
resource_id = save_resource(text)
|
|
28
29
|
append_to_daily_log(text) if Llmemory.configuration.daily_logs_enabled && @storage.respond_to?(:save_daily_log_entry)
|
|
29
30
|
items = @extractor.extract_items(text)
|
|
31
|
+
contents = items.map do |item|
|
|
32
|
+
item.is_a?(Hash) ? (item["content"] || item[:content]).to_s : item.to_s
|
|
33
|
+
end
|
|
34
|
+
classifications = @extractor.classify_items(contents)
|
|
30
35
|
updates_by_category = {}
|
|
31
36
|
|
|
32
37
|
items.each do |item|
|
|
33
38
|
content = item.is_a?(Hash) ? (item["content"] || item[:content]) : item.to_s
|
|
34
39
|
importance = (item["importance"] || item[:importance] || 0.7).to_f
|
|
35
|
-
cat = @extractor.classify_item(content)
|
|
40
|
+
cat = classifications[content] || @extractor.classify_item(content)
|
|
36
41
|
updates_by_category[cat] ||= []
|
|
37
42
|
updates_by_category[cat] << content.to_s
|
|
38
43
|
save_item(category: cat, item: item, source_resource_id: resource_id, importance: importance)
|
|
@@ -48,8 +53,8 @@ module Llmemory
|
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
def retrieve(query)
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
candidates = search_candidates(query, top_k: 20)
|
|
57
|
+
Llmemory::Retrieval::ContextAssembler.new.assemble(candidates)
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
def search_candidates(query, user_id: nil, top_k: 20)
|
|
@@ -16,9 +16,14 @@ module Llmemory
|
|
|
16
16
|
module Storages
|
|
17
17
|
def self.build(store: nil, base_path: nil, database_url: nil, cipher: nil)
|
|
18
18
|
resolved_cipher = cipher || Llmemory.build_cipher
|
|
19
|
-
|
|
19
|
+
store_type = (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
20
|
+
case store_type
|
|
20
21
|
when :memory
|
|
21
|
-
|
|
22
|
+
if Llmemory.configuration.shared_memory_stores
|
|
23
|
+
shared_memory_storage
|
|
24
|
+
else
|
|
25
|
+
MemoryStorage.new
|
|
26
|
+
end
|
|
22
27
|
when :file
|
|
23
28
|
FileStorage.new(
|
|
24
29
|
base_path: base_path || Llmemory.configuration.long_term_storage_path,
|
|
@@ -36,6 +41,14 @@ module Llmemory
|
|
|
36
41
|
MemoryStorage.new
|
|
37
42
|
end
|
|
38
43
|
end
|
|
44
|
+
|
|
45
|
+
def self.shared_memory_storage
|
|
46
|
+
@shared_memory_storage ||= MemoryStorage.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.reset_shared_singletons!
|
|
50
|
+
@shared_memory_storage = nil
|
|
51
|
+
end
|
|
39
52
|
end
|
|
40
53
|
end
|
|
41
54
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "../../../crypto/field_helpers"
|
|
6
|
+
require_relative "../../../active_record_helpers"
|
|
6
7
|
|
|
7
8
|
module Llmemory
|
|
8
9
|
module LongTerm
|
|
@@ -10,6 +11,7 @@ module Llmemory
|
|
|
10
11
|
module Storages
|
|
11
12
|
class ActiveRecordStorage < Base
|
|
12
13
|
include Llmemory::Crypto::FieldHelpers
|
|
14
|
+
include Llmemory::ActiveRecordHelpers
|
|
13
15
|
|
|
14
16
|
def initialize(cipher: nil)
|
|
15
17
|
@cipher = cipher || Llmemory.build_cipher
|
|
@@ -64,7 +66,7 @@ module Llmemory
|
|
|
64
66
|
rec = LlmemoryCategory.find_or_initialize_by(user_id: user_id, category_name: category_name)
|
|
65
67
|
rec.content = enc(content)
|
|
66
68
|
rec.updated_at = Time.current
|
|
67
|
-
rec.save!
|
|
69
|
+
with_unique_retry { rec.save! }
|
|
68
70
|
true
|
|
69
71
|
end
|
|
70
72
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
3
4
|
require_relative "node"
|
|
4
5
|
require_relative "edge"
|
|
5
6
|
require_relative "knowledge_graph"
|
|
@@ -14,9 +15,10 @@ module Llmemory
|
|
|
14
15
|
class Memory
|
|
15
16
|
include Llmemory::MemoryModule
|
|
16
17
|
|
|
17
|
-
def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil, cipher: nil)
|
|
18
|
+
def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil, cipher: nil, forget_log_store: nil)
|
|
18
19
|
@user_id = user_id
|
|
19
20
|
@cipher = cipher || Llmemory.build_cipher
|
|
21
|
+
@forget_log_store = forget_log_store
|
|
20
22
|
@graph_storage = storage || Storages.build(cipher: @cipher)
|
|
21
23
|
@kg = KnowledgeGraph.new(user_id: user_id, storage: @graph_storage)
|
|
22
24
|
@conflict_resolver = ConflictResolver.new(@kg)
|
|
@@ -105,6 +107,7 @@ module Llmemory
|
|
|
105
107
|
# store only exposes soft archive_edge). Both modes route to archive
|
|
106
108
|
# for now; behavior is the same — kept for API uniformity.
|
|
107
109
|
archived = Array(ids).map(&:to_s).select { |edge_id| @kg.archive_edge(edge_id) }
|
|
110
|
+
remove_edge_embeddings(archived)
|
|
108
111
|
forget_log.record(@user_id, memory_type: "graph_based", ids: archived, reason: reason)
|
|
109
112
|
Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "graph_based", user_id: @user_id, count: archived.size, mode: mode)
|
|
110
113
|
archived.size
|
|
@@ -147,6 +150,14 @@ module Llmemory
|
|
|
147
150
|
subject_id = name_to_id[subject] || @kg.add_node(entity_type: "concept", name: subject, properties: { "provenance" => provenance })
|
|
148
151
|
object_id = name_to_id[object] || @kg.add_node(entity_type: "concept", name: object, properties: { "provenance" => provenance })
|
|
149
152
|
|
|
153
|
+
existing = @kg.find_edges(
|
|
154
|
+
subject: subject_id,
|
|
155
|
+
predicate: predicate,
|
|
156
|
+
object: object_id,
|
|
157
|
+
include_archived: false
|
|
158
|
+
)
|
|
159
|
+
next if existing.any?
|
|
160
|
+
|
|
150
161
|
edge = Edge.new(
|
|
151
162
|
id: nil,
|
|
152
163
|
user_id: @user_id,
|
|
@@ -157,7 +168,8 @@ module Llmemory
|
|
|
157
168
|
created_at: Time.now,
|
|
158
169
|
archived_at: nil
|
|
159
170
|
)
|
|
160
|
-
@conflict_resolver.resolve(edge)
|
|
171
|
+
archived_ids = @conflict_resolver.resolve(edge)
|
|
172
|
+
remove_edge_embeddings(archived_ids)
|
|
161
173
|
edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: { "provenance" => provenance })
|
|
162
174
|
|
|
163
175
|
edge_text = "#{subject} #{predicate} #{object}"
|
|
@@ -180,8 +192,10 @@ module Llmemory
|
|
|
180
192
|
vector_results = @vector_store.search(emb, top_k: top_k, user_id: @user_id)
|
|
181
193
|
end
|
|
182
194
|
|
|
183
|
-
out = vector_results.
|
|
195
|
+
out = vector_results.filter_map do |v|
|
|
184
196
|
id = v[:id] || v["id"]
|
|
197
|
+
next unless active_edge?(id)
|
|
198
|
+
|
|
185
199
|
meta = v[:metadata] || v["metadata"] || {}
|
|
186
200
|
{ id: id, text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
|
|
187
201
|
end
|
|
@@ -217,9 +231,14 @@ module Llmemory
|
|
|
217
231
|
|
|
218
232
|
def extract_node_ids_from_text(text)
|
|
219
233
|
return [] if text.to_s.empty?
|
|
234
|
+
|
|
235
|
+
query_tokens = Llmemory::Tokenizer.tokenize(text).to_set
|
|
220
236
|
ids = []
|
|
221
|
-
@kg.list_nodes.each do |
|
|
222
|
-
|
|
237
|
+
@kg.list_nodes.each do |node|
|
|
238
|
+
name_tokens = Llmemory::Tokenizer.tokenize(node.name.to_s)
|
|
239
|
+
next if name_tokens.empty?
|
|
240
|
+
|
|
241
|
+
ids << node.id if name_tokens.all? { |token| query_tokens.include?(token) }
|
|
223
242
|
end
|
|
224
243
|
ids
|
|
225
244
|
end
|
|
@@ -244,6 +263,21 @@ module Llmemory
|
|
|
244
263
|
store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
|
|
245
264
|
)
|
|
246
265
|
end
|
|
266
|
+
|
|
267
|
+
def remove_edge_embeddings(edge_ids)
|
|
268
|
+
return unless @vector_store.respond_to?(:delete)
|
|
269
|
+
|
|
270
|
+
Array(edge_ids).each do |edge_id|
|
|
271
|
+
@vector_store.delete(id: edge_id, user_id: @user_id)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def active_edge?(edge_id)
|
|
276
|
+
return false if edge_id.nil?
|
|
277
|
+
|
|
278
|
+
@kg.find_edges(subject: nil, predicate: nil, object: nil, include_archived: false)
|
|
279
|
+
.any? { |edge| edge.id.to_s == edge_id.to_s }
|
|
280
|
+
end
|
|
247
281
|
end
|
|
248
282
|
end
|
|
249
283
|
end
|
|
@@ -9,9 +9,14 @@ module Llmemory
|
|
|
9
9
|
module Storages
|
|
10
10
|
def self.build(store: nil, cipher: nil)
|
|
11
11
|
resolved_cipher = cipher || Llmemory.build_cipher
|
|
12
|
-
|
|
12
|
+
store_type = (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
13
|
+
case store_type
|
|
13
14
|
when :memory
|
|
14
|
-
|
|
15
|
+
if Llmemory.configuration.shared_memory_stores
|
|
16
|
+
shared_memory_storage
|
|
17
|
+
else
|
|
18
|
+
MemoryStorage.new
|
|
19
|
+
end
|
|
15
20
|
when :active_record, :activerecord
|
|
16
21
|
require_relative "storages/active_record_storage"
|
|
17
22
|
ActiveRecordStorage.new(cipher: resolved_cipher)
|
|
@@ -21,6 +26,14 @@ module Llmemory
|
|
|
21
26
|
"graph_based long-term memory supports long_term_store :memory or :active_record; got #{store_name.inspect}"
|
|
22
27
|
end
|
|
23
28
|
end
|
|
29
|
+
|
|
30
|
+
def self.shared_memory_storage
|
|
31
|
+
@shared_memory_storage ||= MemoryStorage.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.reset_shared_singletons!
|
|
35
|
+
@shared_memory_storage = nil
|
|
36
|
+
end
|
|
24
37
|
end
|
|
25
38
|
end
|
|
26
39
|
end
|
|
@@ -22,9 +22,10 @@ module Llmemory
|
|
|
22
22
|
|
|
23
23
|
attr_reader :user_id, :storage
|
|
24
24
|
|
|
25
|
-
def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil)
|
|
25
|
+
def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil, forget_log_store: nil)
|
|
26
26
|
@user_id = user_id
|
|
27
27
|
@cipher = cipher || Llmemory.build_cipher
|
|
28
|
+
@forget_log_store = forget_log_store
|
|
28
29
|
@storage = storage || Storages.build(cipher: @cipher)
|
|
29
30
|
@vector_store = vector_store
|
|
30
31
|
@vector_explicit = !vector_store.nil?
|
|
@@ -71,11 +72,13 @@ module Llmemory
|
|
|
71
72
|
# Retrieval Engine integration: skills ranked by relevance, recency and
|
|
72
73
|
# proven utility (success rate exposed as importance). Hybrid (vector +
|
|
73
74
|
# keyword) when a vector store is active; otherwise keyword-only.
|
|
75
|
+
KEYWORD_MATCH_SCORE = 0.6
|
|
76
|
+
|
|
74
77
|
def search_candidates(query, user_id: nil, top_k: 20)
|
|
75
78
|
uid = user_id || @user_id
|
|
76
79
|
return [] unless uid == @user_id
|
|
77
80
|
|
|
78
|
-
keyword = @storage.search_skills(uid, query).first(top_k).map { |raw| candidate_for(raw,
|
|
81
|
+
keyword = @storage.search_skills(uid, query).first(top_k).map { |raw| candidate_for(raw, KEYWORD_MATCH_SCORE) }
|
|
79
82
|
vs = vector_store
|
|
80
83
|
return keyword unless vs
|
|
81
84
|
|
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
7
|
require_relative "../../../crypto/field_helpers"
|
|
8
|
+
require_relative "../../../active_record_helpers"
|
|
8
9
|
|
|
9
10
|
module Llmemory
|
|
10
11
|
module LongTerm
|
|
@@ -15,6 +16,7 @@ module Llmemory
|
|
|
15
16
|
# handles. Mirrors the file-based ActiveRecordStorage pattern.
|
|
16
17
|
class ActiveRecordStorage < Base
|
|
17
18
|
include Llmemory::Crypto::FieldHelpers
|
|
19
|
+
include Llmemory::ActiveRecordHelpers
|
|
18
20
|
|
|
19
21
|
def initialize(cipher: nil)
|
|
20
22
|
@cipher = cipher || Llmemory.build_cipher
|
|
@@ -41,7 +43,7 @@ module Llmemory
|
|
|
41
43
|
name = (data["name"] || data[:name]).to_s
|
|
42
44
|
rec.name_det = enc_det(name) if LlmemorySkill.column_names.include?("name_det")
|
|
43
45
|
rec.created_at ||= Time.current
|
|
44
|
-
rec.save!
|
|
46
|
+
with_unique_retry { rec.save! }
|
|
45
47
|
id
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -43,12 +43,16 @@ module Llmemory
|
|
|
43
43
|
|
|
44
44
|
def merge_items(group)
|
|
45
45
|
contents = group.map { |i| i[:content].to_s }.uniq
|
|
46
|
+
importances = group.map { |i| (i[:importance] || i["importance"]).to_f }.compact
|
|
47
|
+
provenances = group.filter_map { |i| i[:provenance] || i["provenance"] }
|
|
46
48
|
{
|
|
47
49
|
id: "merged_#{SecureRandom.hex(4)}",
|
|
48
50
|
category: group.first[:category],
|
|
49
51
|
content: contents.join("; "),
|
|
50
|
-
source_resource_id: group.first[:source_resource_id]
|
|
51
|
-
|
|
52
|
+
source_resource_id: group.first[:source_resource_id],
|
|
53
|
+
importance: importances.empty? ? nil : importances.max,
|
|
54
|
+
provenance: provenances.last
|
|
55
|
+
}.compact
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
3
5
|
module Llmemory
|
|
4
6
|
module MCP
|
|
5
7
|
# Rack middleware for token-based authentication
|
|
@@ -17,10 +19,8 @@ module Llmemory
|
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def call(env)
|
|
20
|
-
# If no token is configured, skip authentication
|
|
21
22
|
return @app.call(env) unless @token
|
|
22
23
|
|
|
23
|
-
# Check for valid token
|
|
24
24
|
return UNAUTHORIZED_RESPONSE unless valid_token?(env)
|
|
25
25
|
|
|
26
26
|
@app.call(env)
|
|
@@ -50,7 +50,9 @@ module Llmemory
|
|
|
50
50
|
def parse_query_string(query_string)
|
|
51
51
|
query_string.split("&").each_with_object({}) do |pair, hash|
|
|
52
52
|
key, value = pair.split("=", 2)
|
|
53
|
-
|
|
53
|
+
next unless key
|
|
54
|
+
|
|
55
|
+
hash[CGI.unescape(key)] = CGI.unescape(value.to_s)
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
|
data/lib/llmemory/mcp/server.rb
CHANGED
|
@@ -39,7 +39,10 @@ module Llmemory
|
|
|
39
39
|
transport.open
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def run_http(port: 3100, host: "
|
|
42
|
+
def run_http(port: 3100, host: "127.0.0.1", ssl_cert: nil, ssl_key: nil)
|
|
43
|
+
require "socket"
|
|
44
|
+
validate_http_bind!(host)
|
|
45
|
+
|
|
43
46
|
require "webrick"
|
|
44
47
|
|
|
45
48
|
@http_transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
|
@@ -96,13 +99,25 @@ module Llmemory
|
|
|
96
99
|
|
|
97
100
|
def build_rack_app(transport)
|
|
98
101
|
app = ->(env) { transport.handle_request(RackRequest.new(env)) }
|
|
102
|
+
app = Authentication.new(app)
|
|
103
|
+
app
|
|
104
|
+
end
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
if
|
|
102
|
-
|
|
103
|
-
end
|
|
106
|
+
def validate_http_bind!(host)
|
|
107
|
+
return if loopback_host?(host)
|
|
108
|
+
return if ENV["MCP_TOKEN"] && !ENV["MCP_TOKEN"].to_s.empty?
|
|
104
109
|
|
|
105
|
-
|
|
110
|
+
raise Llmemory::ConfigurationError,
|
|
111
|
+
"MCP HTTP server cannot bind to #{host} without MCP_TOKEN; use 127.0.0.1 or set MCP_TOKEN"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def loopback_host?(host)
|
|
115
|
+
case host.to_s
|
|
116
|
+
when "127.0.0.1", "localhost", "::1"
|
|
117
|
+
true
|
|
118
|
+
else
|
|
119
|
+
false
|
|
120
|
+
end
|
|
106
121
|
end
|
|
107
122
|
|
|
108
123
|
def build_rack_env(req)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
# Shared store resolution for MCP tools. Respects global configuration and
|
|
6
|
+
# long_term_type when selecting backends.
|
|
7
|
+
module StoreHelpers
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def short_term_store
|
|
11
|
+
ShortTerm::Stores.build
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def long_term_storage
|
|
15
|
+
if graph_based?
|
|
16
|
+
LongTerm::GraphBased::Storages.build
|
|
17
|
+
else
|
|
18
|
+
LongTerm::FileBased::Storages.build
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def long_term_memory(user_id:)
|
|
23
|
+
if graph_based?
|
|
24
|
+
LongTerm::GraphBased::Memory.new(user_id: user_id, storage: long_term_storage)
|
|
25
|
+
else
|
|
26
|
+
LongTerm::FileBased::Memory.new(user_id: user_id, storage: long_term_storage)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def graph_based?
|
|
31
|
+
Llmemory.configuration.long_term_type.to_sym == :graph_based
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../store_helpers"
|
|
4
|
+
|
|
3
5
|
module Llmemory
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -20,20 +22,21 @@ module Llmemory
|
|
|
20
22
|
|
|
21
23
|
class << self
|
|
22
24
|
def call(user_id:, content:, category: nil, server_context: nil)
|
|
23
|
-
storage = build_storage
|
|
24
|
-
|
|
25
|
-
# If no category provided, use a default
|
|
26
25
|
cat = category || "observations"
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
if StoreHelpers.graph_based?
|
|
28
|
+
memory = StoreHelpers.long_term_memory(user_id: user_id)
|
|
29
|
+
memory.remember_fact(content: content, category: cat)
|
|
30
|
+
else
|
|
31
|
+
storage = StoreHelpers.long_term_storage
|
|
32
|
+
resource_id = "mcp_#{Time.now.to_i}_#{rand(1000)}"
|
|
33
|
+
storage.save_item(
|
|
34
|
+
user_id,
|
|
35
|
+
category: cat,
|
|
36
|
+
content: content,
|
|
37
|
+
source_resource_id: resource_id
|
|
38
|
+
)
|
|
39
|
+
end
|
|
37
40
|
|
|
38
41
|
::MCP::Tool::Response.new([{
|
|
39
42
|
type: "text",
|
|
@@ -45,12 +48,6 @@ module Llmemory
|
|
|
45
48
|
text: "Error saving memory: #{e.message}"
|
|
46
49
|
}], error: true)
|
|
47
50
|
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def build_storage
|
|
52
|
-
LongTerm::FileBased::Storages.build
|
|
53
|
-
end
|
|
54
51
|
end
|
|
55
52
|
end
|
|
56
53
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../store_helpers"
|
|
4
|
+
|
|
3
5
|
module Llmemory
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -48,7 +50,7 @@ module Llmemory
|
|
|
48
50
|
private
|
|
49
51
|
|
|
50
52
|
def search_short_term(user_id, query, limit)
|
|
51
|
-
store =
|
|
53
|
+
store = StoreHelpers.short_term_store
|
|
52
54
|
sessions = store.list_sessions(user_id: user_id)
|
|
53
55
|
results = []
|
|
54
56
|
query_lower = query.downcase
|
|
@@ -74,9 +76,21 @@ module Llmemory
|
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
def search_long_term(user_id, query, limit)
|
|
77
|
-
storage = build_long_term_storage
|
|
78
79
|
results = []
|
|
79
80
|
|
|
81
|
+
if StoreHelpers.graph_based?
|
|
82
|
+
memory = StoreHelpers.long_term_memory(user_id: user_id)
|
|
83
|
+
memory.search_candidates(query, user_id: user_id, top_k: limit).each do |item|
|
|
84
|
+
results << {
|
|
85
|
+
type: "long_term_fact",
|
|
86
|
+
content: item[:text] || item["text"] || item[:id] || item["id"],
|
|
87
|
+
created_at: item[:timestamp] || item["timestamp"]
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
return results
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
storage = StoreHelpers.long_term_storage
|
|
80
94
|
items = storage.search_items(user_id, query)
|
|
81
95
|
items.first(limit).each do |item|
|
|
82
96
|
results << {
|
|
@@ -90,23 +104,6 @@ module Llmemory
|
|
|
90
104
|
results
|
|
91
105
|
end
|
|
92
106
|
|
|
93
|
-
def build_short_term_store
|
|
94
|
-
case Llmemory.configuration.short_term_store.to_sym
|
|
95
|
-
when :memory then ShortTerm::Stores::MemoryStore.new
|
|
96
|
-
when :redis then ShortTerm::Stores::RedisStore.new
|
|
97
|
-
when :postgres then ShortTerm::Stores::PostgresStore.new
|
|
98
|
-
when :active_record, :activerecord
|
|
99
|
-
require_relative "../../short_term/stores/active_record_store"
|
|
100
|
-
ShortTerm::Stores::ActiveRecordStore.new
|
|
101
|
-
else
|
|
102
|
-
ShortTerm::Stores::MemoryStore.new
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def build_long_term_storage
|
|
107
|
-
LongTerm::FileBased::Storages.build
|
|
108
|
-
end
|
|
109
|
-
|
|
110
107
|
def format_results(results)
|
|
111
108
|
return "No memories found matching the query." if results.empty?
|
|
112
109
|
|