llmemory 0.1.10 → 0.1.11
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 +183 -0
- data/exe/llmemory-mcp +44 -0
- data/lib/llmemory/cli/commands/mcp.rb +129 -0
- data/lib/llmemory/cli.rb +4 -1
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +32 -0
- data/lib/llmemory/long_term/file_based/storages/base.rb +8 -0
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +34 -0
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +36 -0
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +26 -0
- data/lib/llmemory/long_term/graph_based/storages/base.rb +4 -0
- data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +23 -0
- data/lib/llmemory/mcp/authentication.rb +70 -0
- data/lib/llmemory/mcp/server.rb +185 -0
- data/lib/llmemory/mcp/tools/memory_add_message.rb +47 -0
- data/lib/llmemory/mcp/tools/memory_consolidate.rb +57 -0
- data/lib/llmemory/mcp/tools/memory_info.rb +79 -0
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +122 -0
- data/lib/llmemory/mcp/tools/memory_save.rb +58 -0
- data/lib/llmemory/mcp/tools/memory_search.rb +134 -0
- data/lib/llmemory/mcp/tools/memory_stats.rb +111 -0
- data/lib/llmemory/mcp/tools/memory_timeline.rb +120 -0
- data/lib/llmemory/mcp/tools/memory_timeline_context.rb +140 -0
- data/lib/llmemory/mcp.rb +8 -0
- data/lib/llmemory/version.rb +1 -1
- metadata +30 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
# Rack middleware for token-based authentication
|
|
6
|
+
# Validates requests against MCP_TOKEN environment variable
|
|
7
|
+
class Authentication
|
|
8
|
+
UNAUTHORIZED_RESPONSE = [
|
|
9
|
+
401,
|
|
10
|
+
{ "Content-Type" => "application/json" },
|
|
11
|
+
['{"error":"Unauthorized: Invalid or missing token"}']
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(app, token: nil)
|
|
15
|
+
@app = app
|
|
16
|
+
@token = token || ENV["MCP_TOKEN"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
# If no token is configured, skip authentication
|
|
21
|
+
return @app.call(env) unless @token
|
|
22
|
+
|
|
23
|
+
# Check for valid token
|
|
24
|
+
return UNAUTHORIZED_RESPONSE unless valid_token?(env)
|
|
25
|
+
|
|
26
|
+
@app.call(env)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def valid_token?(env)
|
|
32
|
+
# Check Authorization header (Bearer token)
|
|
33
|
+
auth_header = env["HTTP_AUTHORIZATION"]
|
|
34
|
+
if auth_header
|
|
35
|
+
# Support both "Bearer <token>" and plain "<token>"
|
|
36
|
+
token = auth_header.sub(/\ABearer\s+/i, "")
|
|
37
|
+
return true if secure_compare(token, @token)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check query string parameter
|
|
41
|
+
query_string = env["QUERY_STRING"] || ""
|
|
42
|
+
query_params = parse_query_string(query_string)
|
|
43
|
+
if query_params["token"]
|
|
44
|
+
return true if secure_compare(query_params["token"], @token)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_query_string(query_string)
|
|
51
|
+
query_string.split("&").each_with_object({}) do |pair, hash|
|
|
52
|
+
key, value = pair.split("=", 2)
|
|
53
|
+
hash[key] = value if key
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Constant-time comparison to prevent timing attacks
|
|
58
|
+
def secure_compare(a, b)
|
|
59
|
+
return false if a.nil? || b.nil?
|
|
60
|
+
return false if a.bytesize != b.bytesize
|
|
61
|
+
|
|
62
|
+
l = a.unpack("C*")
|
|
63
|
+
r = b.unpack("C*")
|
|
64
|
+
result = 0
|
|
65
|
+
l.zip(r) { |x, y| result |= x ^ y }
|
|
66
|
+
result.zero?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "authentication"
|
|
5
|
+
require_relative "tools/memory_search"
|
|
6
|
+
require_relative "tools/memory_save"
|
|
7
|
+
require_relative "tools/memory_retrieve"
|
|
8
|
+
require_relative "tools/memory_timeline"
|
|
9
|
+
require_relative "tools/memory_add_message"
|
|
10
|
+
require_relative "tools/memory_consolidate"
|
|
11
|
+
require_relative "tools/memory_stats"
|
|
12
|
+
require_relative "tools/memory_info"
|
|
13
|
+
require_relative "tools/memory_timeline_context"
|
|
14
|
+
|
|
15
|
+
module Llmemory
|
|
16
|
+
module MCP
|
|
17
|
+
class Server
|
|
18
|
+
attr_reader :server
|
|
19
|
+
|
|
20
|
+
def initialize(name: "llmemory", version: nil)
|
|
21
|
+
@server = ::MCP::Server.new(
|
|
22
|
+
name: name,
|
|
23
|
+
version: version || Llmemory::VERSION,
|
|
24
|
+
instructions: instructions_text,
|
|
25
|
+
tools: all_tools
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run_stdio
|
|
30
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(@server)
|
|
31
|
+
transport.open
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_http(port: 3100, host: "0.0.0.0", ssl_cert: nil, ssl_key: nil)
|
|
35
|
+
require "webrick"
|
|
36
|
+
|
|
37
|
+
@http_transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
|
38
|
+
app = build_rack_app(@http_transport)
|
|
39
|
+
|
|
40
|
+
webrick_options = {
|
|
41
|
+
Port: port,
|
|
42
|
+
BindAddress: host,
|
|
43
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
|
|
44
|
+
AccessLog: []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Configure SSL/HTTPS if certificates provided
|
|
48
|
+
use_ssl = ssl_cert && ssl_key
|
|
49
|
+
if use_ssl
|
|
50
|
+
require "webrick/https"
|
|
51
|
+
require "openssl"
|
|
52
|
+
|
|
53
|
+
webrick_options[:SSLEnable] = true
|
|
54
|
+
webrick_options[:SSLCertificate] = OpenSSL::X509::Certificate.new(File.read(ssl_cert))
|
|
55
|
+
webrick_options[:SSLPrivateKey] = OpenSSL::PKey::RSA.new(File.read(ssl_key))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
webrick_server = WEBrick::HTTPServer.new(webrick_options)
|
|
59
|
+
|
|
60
|
+
webrick_server.mount_proc "/" do |req, res|
|
|
61
|
+
rack_env = build_rack_env(req)
|
|
62
|
+
status, headers, body = app.call(rack_env)
|
|
63
|
+
|
|
64
|
+
res.status = status
|
|
65
|
+
headers.each { |key, value| res[key] = value }
|
|
66
|
+
res.body = body.is_a?(Array) ? body.join : body
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
trap("INT") { webrick_server.shutdown }
|
|
70
|
+
trap("TERM") { webrick_server.shutdown }
|
|
71
|
+
|
|
72
|
+
protocol = use_ssl ? "https" : "http"
|
|
73
|
+
$stderr.puts "llmemory MCP server listening on #{protocol}://#{host}:#{port}"
|
|
74
|
+
$stderr.puts "Authentication: #{ENV["MCP_TOKEN"] ? "enabled (MCP_TOKEN set)" : "disabled"}"
|
|
75
|
+
|
|
76
|
+
webrick_server.start
|
|
77
|
+
ensure
|
|
78
|
+
@http_transport&.close
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns a Rack app for use with custom servers (Puma, etc.)
|
|
82
|
+
def rack_app
|
|
83
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
|
84
|
+
build_rack_app(transport)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def build_rack_app(transport)
|
|
90
|
+
app = ->(env) { transport.handle_request(RackRequest.new(env)) }
|
|
91
|
+
|
|
92
|
+
# Wrap with authentication if MCP_TOKEN is set
|
|
93
|
+
if ENV["MCP_TOKEN"]
|
|
94
|
+
app = Authentication.new(app)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
app
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_rack_env(req)
|
|
101
|
+
env = {
|
|
102
|
+
"REQUEST_METHOD" => req.request_method,
|
|
103
|
+
"SCRIPT_NAME" => "",
|
|
104
|
+
"PATH_INFO" => req.path,
|
|
105
|
+
"QUERY_STRING" => req.query_string || "",
|
|
106
|
+
"SERVER_NAME" => req.host,
|
|
107
|
+
"SERVER_PORT" => req.port.to_s,
|
|
108
|
+
"HTTP_HOST" => req["Host"],
|
|
109
|
+
"rack.input" => StringIO.new(req.body || ""),
|
|
110
|
+
"rack.url_scheme" => req.ssl? ? "https" : "http"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Copy HTTP headers
|
|
114
|
+
req.header.each do |key, values|
|
|
115
|
+
header_key = "HTTP_#{key.upcase.tr("-", "_")}"
|
|
116
|
+
env[header_key] = values.is_a?(Array) ? values.join(", ") : values
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
env
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Simple wrapper to make Rack env look like a Rack request
|
|
123
|
+
class RackRequest
|
|
124
|
+
def initialize(env)
|
|
125
|
+
@env = env
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def env
|
|
129
|
+
@env
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def body
|
|
133
|
+
@env["rack.input"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def params
|
|
137
|
+
@params ||= parse_query_string(@env["QUERY_STRING"] || "")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def parse_query_string(qs)
|
|
143
|
+
qs.split("&").each_with_object({}) do |pair, hash|
|
|
144
|
+
key, value = pair.split("=", 2)
|
|
145
|
+
hash[key] = value if key
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def all_tools
|
|
151
|
+
[
|
|
152
|
+
Tools::MemorySearch,
|
|
153
|
+
Tools::MemorySave,
|
|
154
|
+
Tools::MemoryRetrieve,
|
|
155
|
+
Tools::MemoryTimeline,
|
|
156
|
+
Tools::MemoryTimelineContext,
|
|
157
|
+
Tools::MemoryAddMessage,
|
|
158
|
+
Tools::MemoryConsolidate,
|
|
159
|
+
Tools::MemoryStats,
|
|
160
|
+
Tools::MemoryInfo
|
|
161
|
+
]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def instructions_text
|
|
165
|
+
<<~INSTRUCTIONS
|
|
166
|
+
llmemory MCP Server - Persistent Memory System for LLM Agents
|
|
167
|
+
|
|
168
|
+
This server provides tools to manage persistent memory for users:
|
|
169
|
+
- Search memories by semantic query
|
|
170
|
+
- Save new observations and facts
|
|
171
|
+
- Retrieve context optimized for LLM inference
|
|
172
|
+
- Manage conversation sessions
|
|
173
|
+
- Consolidate conversations into long-term memory
|
|
174
|
+
|
|
175
|
+
RECOMMENDED WORKFLOW:
|
|
176
|
+
1. At start of conversation: use memory_retrieve to get relevant context
|
|
177
|
+
2. During conversation: use memory_save for important observations
|
|
178
|
+
3. At end: use memory_consolidate to persist the conversation
|
|
179
|
+
|
|
180
|
+
Use memory_info tool for detailed usage guidelines.
|
|
181
|
+
INSTRUCTIONS
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryAddMessage < ::MCP::Tool
|
|
7
|
+
description "Add a message to the short-term conversation memory."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
session_id: { type: "string", description: "Session identifier (default: 'default')" },
|
|
13
|
+
role: { type: "string", enum: ["user", "assistant", "system"], description: "Message role" },
|
|
14
|
+
content: { type: "string", description: "Message content" }
|
|
15
|
+
},
|
|
16
|
+
required: ["user_id", "role", "content"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(user_id:, role:, content:, session_id: nil, server_context: nil)
|
|
21
|
+
session = session_id || "default"
|
|
22
|
+
|
|
23
|
+
memory = Llmemory::Memory.new(user_id: user_id, session_id: session)
|
|
24
|
+
memory.add_message(role: role.to_sym, content: content)
|
|
25
|
+
|
|
26
|
+
::MCP::Tool::Response.new([{
|
|
27
|
+
type: "text",
|
|
28
|
+
text: "Message added to session '#{session}'.\nRole: #{role}\nContent: #{truncate(content, 100)}"
|
|
29
|
+
}])
|
|
30
|
+
rescue => e
|
|
31
|
+
::MCP::Tool::Response.new([{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: "Error adding message: #{e.message}"
|
|
34
|
+
}], error: true)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def truncate(text, max_length)
|
|
40
|
+
return text if text.length <= max_length
|
|
41
|
+
"#{text[0, max_length]}..."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryConsolidate < ::MCP::Tool
|
|
7
|
+
description "Consolidate current short-term conversation into long-term memory. Extracts facts and observations from the conversation."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
session_id: { type: "string", description: "Session identifier (default: 'default')" },
|
|
13
|
+
clear_session: { type: "boolean", description: "Clear session after consolidation (default: false)" }
|
|
14
|
+
},
|
|
15
|
+
required: ["user_id"]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def call(user_id:, session_id: nil, clear_session: nil, server_context: nil)
|
|
20
|
+
session = session_id || "default"
|
|
21
|
+
should_clear = clear_session || false
|
|
22
|
+
|
|
23
|
+
memory = Llmemory::Memory.new(user_id: user_id, session_id: session)
|
|
24
|
+
|
|
25
|
+
messages = memory.messages
|
|
26
|
+
if messages.empty?
|
|
27
|
+
return ::MCP::Tool::Response.new([{
|
|
28
|
+
type: "text",
|
|
29
|
+
text: "No messages to consolidate in session '#{session}'."
|
|
30
|
+
}])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
message_count = messages.size
|
|
34
|
+
memory.consolidate!
|
|
35
|
+
|
|
36
|
+
if should_clear
|
|
37
|
+
memory.clear_session!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
response_text = "Consolidated #{message_count} messages from session '#{session}' into long-term memory."
|
|
41
|
+
response_text += "\nSession cleared." if should_clear
|
|
42
|
+
|
|
43
|
+
::MCP::Tool::Response.new([{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: response_text
|
|
46
|
+
}])
|
|
47
|
+
rescue => e
|
|
48
|
+
::MCP::Tool::Response.new([{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: "Error consolidating memory: #{e.message}"
|
|
51
|
+
}], error: true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryInfo < ::MCP::Tool
|
|
7
|
+
description "Get documentation on how to use llmemory tools effectively. Read this first to understand the memory system."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {}
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def call(server_context: nil)
|
|
15
|
+
::MCP::Tool::Response.new([{
|
|
16
|
+
type: "text",
|
|
17
|
+
text: documentation_text
|
|
18
|
+
}])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def documentation_text
|
|
24
|
+
<<~DOC
|
|
25
|
+
# llmemory - Memory System for LLM Agents
|
|
26
|
+
|
|
27
|
+
## Overview
|
|
28
|
+
llmemory provides persistent memory across conversations, combining:
|
|
29
|
+
- **Short-term memory**: Recent conversation messages per session
|
|
30
|
+
- **Long-term memory**: Extracted facts, preferences, and observations
|
|
31
|
+
|
|
32
|
+
## Recommended Workflow
|
|
33
|
+
|
|
34
|
+
### 1. Start of Conversation
|
|
35
|
+
Use memory_retrieve to get relevant context:
|
|
36
|
+
```
|
|
37
|
+
memory_retrieve(query: "<user's first message>", user_id: "user123")
|
|
38
|
+
```
|
|
39
|
+
This returns relevant context from both short and long-term memory.
|
|
40
|
+
|
|
41
|
+
### 2. During Conversation
|
|
42
|
+
Save important observations as you learn them:
|
|
43
|
+
```
|
|
44
|
+
memory_save(user_id: "user123", content: "User prefers concise answers")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 3. End of Conversation
|
|
48
|
+
Consolidate the conversation to extract and store facts:
|
|
49
|
+
```
|
|
50
|
+
memory_consolidate(user_id: "user123", session_id: "default")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Tools Reference
|
|
54
|
+
|
|
55
|
+
| Tool | Purpose |
|
|
56
|
+
|------|---------|
|
|
57
|
+
| memory_search | Find specific memories by query |
|
|
58
|
+
| memory_save | Store a new observation/fact |
|
|
59
|
+
| memory_retrieve | Get context for LLM inference |
|
|
60
|
+
| memory_timeline | Get recent memories chronologically |
|
|
61
|
+
| memory_timeline_context | Get N items before/after a specific memory |
|
|
62
|
+
| memory_add_message | Add message to short-term |
|
|
63
|
+
| memory_consolidate | Extract facts from conversation |
|
|
64
|
+
| memory_stats | Get memory statistics |
|
|
65
|
+
|
|
66
|
+
## Best Practices
|
|
67
|
+
|
|
68
|
+
1. **Be specific** when saving observations
|
|
69
|
+
2. **Use categories** to organize facts (preferences, work, personal, technical)
|
|
70
|
+
3. **Consolidate regularly** to not lose important information
|
|
71
|
+
4. **Search before asking** - check if you already know something
|
|
72
|
+
5. **Use session_id** to separate different conversation contexts
|
|
73
|
+
DOC
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryRetrieve < ::MCP::Tool
|
|
7
|
+
description "Retrieve relevant context from memory optimized for LLM inference. Combines short-term conversation history with relevant long-term memories."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
query: { type: "string", description: "Context query (e.g., current user message)" },
|
|
12
|
+
user_id: { type: "string", description: "User identifier" },
|
|
13
|
+
session_id: { type: "string", description: "Session identifier (default: 'default')" },
|
|
14
|
+
max_tokens: { type: "integer", description: "Maximum tokens for context (default: 2000)" },
|
|
15
|
+
include_timeline_context: { type: "boolean", description: "Include N items before/after top matches (default: false)" },
|
|
16
|
+
timeline_window: { type: "integer", description: "Number of items before/after for timeline context (default: 3)" }
|
|
17
|
+
},
|
|
18
|
+
required: ["query", "user_id"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(query:, user_id:, session_id: nil, max_tokens: nil, include_timeline_context: nil, timeline_window: nil, server_context: nil)
|
|
23
|
+
session = session_id || "default"
|
|
24
|
+
tokens = max_tokens || 2000
|
|
25
|
+
include_timeline = include_timeline_context == true
|
|
26
|
+
window = timeline_window || 3
|
|
27
|
+
|
|
28
|
+
memory = Llmemory::Memory.new(user_id: user_id, session_id: session)
|
|
29
|
+
context = memory.retrieve(query, max_tokens: tokens)
|
|
30
|
+
|
|
31
|
+
# Add timeline context if requested
|
|
32
|
+
if include_timeline && !context.to_s.strip.empty?
|
|
33
|
+
timeline_context = fetch_timeline_context(user_id, query, window)
|
|
34
|
+
context = "#{context}\n\n#{timeline_context}" unless timeline_context.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if context.to_s.strip.empty?
|
|
38
|
+
::MCP::Tool::Response.new([{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: "No relevant context found for this query."
|
|
41
|
+
}])
|
|
42
|
+
else
|
|
43
|
+
::MCP::Tool::Response.new([{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: context
|
|
46
|
+
}])
|
|
47
|
+
end
|
|
48
|
+
rescue => e
|
|
49
|
+
::MCP::Tool::Response.new([{
|
|
50
|
+
type: "text",
|
|
51
|
+
text: "Error retrieving context: #{e.message}"
|
|
52
|
+
}], error: true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fetch_timeline_context(user_id, query, window)
|
|
58
|
+
storage = build_storage
|
|
59
|
+
items = storage.search_items(user_id, query)
|
|
60
|
+
return "" if items.empty?
|
|
61
|
+
|
|
62
|
+
# Get timeline context around the first match
|
|
63
|
+
top_item = items.first
|
|
64
|
+
item_id = top_item[:id] || top_item["id"]
|
|
65
|
+
return "" unless item_id
|
|
66
|
+
|
|
67
|
+
result = storage.get_items_around(user_id, item_id, before: window, after: window)
|
|
68
|
+
format_timeline_context(result, item_id)
|
|
69
|
+
rescue
|
|
70
|
+
""
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_storage
|
|
74
|
+
if Llmemory.configuration.long_term_type.to_s == "graph_based"
|
|
75
|
+
LongTerm::GraphBased::Storages.build
|
|
76
|
+
else
|
|
77
|
+
LongTerm::FileBased::Storages.build
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_timeline_context(result, reference)
|
|
82
|
+
return "" if result[:before].empty? && result[:target].nil? && result[:after].empty?
|
|
83
|
+
|
|
84
|
+
lines = ["=== TIMELINE CONTEXT ===", ""]
|
|
85
|
+
lines << "Events around match '#{reference}':"
|
|
86
|
+
lines << ""
|
|
87
|
+
|
|
88
|
+
result[:before].each do |item|
|
|
89
|
+
lines << " [BEFORE] #{format_item(item)}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if result[:target]
|
|
93
|
+
lines << " [MATCH] #{format_item(result[:target])}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result[:after].each do |item|
|
|
97
|
+
lines << " [AFTER] #{format_item(item)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
lines << ""
|
|
101
|
+
lines << "=== END TIMELINE CONTEXT ==="
|
|
102
|
+
lines.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def format_item(item)
|
|
106
|
+
ts = item[:created_at] || item["created_at"]
|
|
107
|
+
ts_str = ts.respond_to?(:strftime) ? ts.strftime("%Y-%m-%d") : ts.to_s[0, 10]
|
|
108
|
+
content = item[:content] || item["content"]
|
|
109
|
+
category = item[:category] || item["category"]
|
|
110
|
+
cat_str = category ? "[#{category}] " : ""
|
|
111
|
+
"#{ts_str} #{cat_str}#{truncate(content, 80)}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def truncate(text, max)
|
|
115
|
+
return text.to_s if text.to_s.length <= max
|
|
116
|
+
"#{text.to_s[0, max]}..."
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemorySave < ::MCP::Tool
|
|
7
|
+
description "Save a new observation or fact to long-term memory. Use this to remember important information about the user."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
content: { type: "string", description: "The observation or fact to remember" },
|
|
13
|
+
category: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Category for the memory (e.g., preferences, work, personal). If omitted, will be auto-classified."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
required: ["user_id", "content"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(user_id:, content:, category: nil, server_context: nil)
|
|
23
|
+
storage = build_storage
|
|
24
|
+
|
|
25
|
+
# If no category provided, use a default
|
|
26
|
+
cat = category || "observations"
|
|
27
|
+
|
|
28
|
+
# Generate a simple resource ID for tracking
|
|
29
|
+
resource_id = "mcp_#{Time.now.to_i}_#{rand(1000)}"
|
|
30
|
+
|
|
31
|
+
storage.save_item(
|
|
32
|
+
user_id,
|
|
33
|
+
category: cat,
|
|
34
|
+
content: content,
|
|
35
|
+
source_resource_id: resource_id
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
::MCP::Tool::Response.new([{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: "Memory saved successfully.\nCategory: #{cat}\nContent: #{content}"
|
|
41
|
+
}])
|
|
42
|
+
rescue => e
|
|
43
|
+
::MCP::Tool::Response.new([{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: "Error saving memory: #{e.message}"
|
|
46
|
+
}], error: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_storage
|
|
52
|
+
LongTerm::FileBased::Storages.build
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|