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.
@@ -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