supermemory 0.1.0

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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+
7
+ module Supermemory
8
+ class Client
9
+ RETRYABLE_STATUS_CODES = [408, 409, 429, 500, 502, 503, 504].freeze
10
+ INITIAL_RETRY_DELAY = 0.5
11
+ MAX_RETRY_DELAY = 8.0
12
+
13
+ attr_reader :api_key, :base_url, :timeout, :max_retries
14
+
15
+ # @param api_key [String, nil] API key (defaults to SUPERMEMORY_API_KEY env var)
16
+ # @param base_url [String, nil] Base URL (defaults to https://api.supermemory.ai)
17
+ # @param timeout [Integer] Request timeout in seconds (default: 60)
18
+ # @param max_retries [Integer] Max retry attempts (default: 2)
19
+ # @param extra_headers [Hash] Additional headers to send with every request
20
+ def initialize(api_key: nil, base_url: nil, timeout: nil, max_retries: nil, extra_headers: {})
21
+ config = Supermemory.configuration
22
+ @api_key = api_key || config.api_key
23
+ @base_url = base_url || config.base_url
24
+ @timeout = timeout || config.timeout
25
+ @max_retries = max_retries || config.max_retries
26
+ @extra_headers = extra_headers.merge(config.extra_headers)
27
+
28
+ unless @api_key
29
+ raise Supermemory::Error.new("API key is required. Set via Supermemory.configure or pass api_key:")
30
+ end
31
+ end
32
+
33
+ # Top-level convenience: add a document
34
+ # @param content [String] Text, URL, or file content
35
+ # @param options [Hash] Additional parameters (container_tag, custom_id, metadata, entity_context)
36
+ # @return [Hash] { "id" => "...", "status" => "..." }
37
+ def add(content:, **options)
38
+ documents.add(content: content, **options)
39
+ end
40
+
41
+ # Top-level convenience: get user profile
42
+ # @param container_tag [String] User/project identifier
43
+ # @param q [String, nil] Optional search query
44
+ # @param threshold [Float, nil] Score threshold for search results
45
+ # @return [Hash]
46
+ def profile(container_tag:, q: nil, threshold: nil)
47
+ body = { container_tag: container_tag }
48
+ body[:q] = q if q
49
+ body[:threshold] = threshold if threshold
50
+ post("/v4/profile", body)
51
+ end
52
+
53
+ # @return [Supermemory::Resources::Documents]
54
+ def documents
55
+ @documents ||= Resources::Documents.new(self)
56
+ end
57
+
58
+ # @return [Supermemory::Resources::Search]
59
+ def search
60
+ @search ||= Resources::Search.new(self)
61
+ end
62
+
63
+ # @return [Supermemory::Resources::Memories]
64
+ def memories
65
+ @memories ||= Resources::Memories.new(self)
66
+ end
67
+
68
+ # @return [Supermemory::Resources::Settings]
69
+ def settings
70
+ @settings ||= Resources::Settings.new(self)
71
+ end
72
+
73
+ # @return [Supermemory::Resources::Connections]
74
+ def connections
75
+ @connections ||= Resources::Connections.new(self)
76
+ end
77
+
78
+ # Low-level HTTP methods
79
+
80
+ # @param path [String]
81
+ # @param params [Hash, nil]
82
+ # @return [Hash, nil]
83
+ def get(path, params = nil)
84
+ request(:get, path, params: params)
85
+ end
86
+
87
+ # @param path [String]
88
+ # @param body [Hash, nil]
89
+ # @return [Hash, nil]
90
+ def post(path, body = nil)
91
+ request(:post, path, body: body)
92
+ end
93
+
94
+ # @param path [String]
95
+ # @param body [Hash, nil]
96
+ # @return [Hash, nil]
97
+ def patch(path, body = nil)
98
+ request(:patch, path, body: body)
99
+ end
100
+
101
+ # @param path [String]
102
+ # @param body [Hash, nil]
103
+ # @return [Hash, nil]
104
+ def delete(path, body = nil)
105
+ request(:delete, path, body: body)
106
+ end
107
+
108
+ # @param path [String]
109
+ # @param body [Hash]
110
+ # @return [Hash, nil]
111
+ def multipart_post(path, body)
112
+ request(:post, path, body: body, multipart: true)
113
+ end
114
+
115
+ private
116
+
117
+ def request(method, path, body: nil, params: nil, multipart: false)
118
+ attempts = 0
119
+
120
+ begin
121
+ response = connection(multipart: multipart).run_request(method, path, nil, nil) do |req|
122
+ req.params = params if params
123
+ if multipart
124
+ body.each { |k, v| req.body[k] = v }
125
+ elsif body
126
+ req.body = body.to_json
127
+ end
128
+ end
129
+
130
+ handle_response(response)
131
+ rescue Faraday::TimeoutError => e
132
+ raise Supermemory::APITimeoutError.new("Request timed out: #{e.message}")
133
+ rescue Faraday::ConnectionFailed => e
134
+ raise Supermemory::APIConnectionError.new("Connection failed: #{e.message}")
135
+ rescue Supermemory::APIError => e
136
+ attempts += 1
137
+ if attempts <= @max_retries && retryable?(e.status)
138
+ sleep(retry_delay(attempts))
139
+ retry
140
+ end
141
+ raise
142
+ end
143
+ end
144
+
145
+ def handle_response(response)
146
+ status = response.status
147
+ body = parse_body(response.body)
148
+ headers = response.headers
149
+
150
+ return body if status >= 200 && status < 300
151
+
152
+ error_class = ERROR_MAP[status] || (status >= 500 ? InternalServerError : APIError)
153
+ message = body.is_a?(Hash) ? (body["error"] || body["message"] || body.to_s) : body.to_s
154
+ raise error_class.new(message, status: status, body: body, headers: headers)
155
+ end
156
+
157
+ def parse_body(body)
158
+ return nil if body.nil? || body.empty?
159
+
160
+ JSON.parse(body)
161
+ rescue JSON::ParserError
162
+ body
163
+ end
164
+
165
+ def connection(multipart: false)
166
+ @connections_cache ||= {}
167
+ cache_key = multipart ? :multipart : :json
168
+ @connections_cache[cache_key] ||= Faraday.new(url: @base_url) do |f|
169
+ f.options.timeout = @timeout
170
+ f.options.open_timeout = 5
171
+ if multipart
172
+ f.request :multipart
173
+ else
174
+ f.headers["Content-Type"] = "application/json"
175
+ end
176
+ f.headers["Authorization"] = "Bearer #{@api_key}"
177
+ @extra_headers.each { |k, v| f.headers[k.to_s] = v.to_s }
178
+ f.adapter Faraday.default_adapter
179
+ end
180
+ end
181
+
182
+ def retryable?(status)
183
+ RETRYABLE_STATUS_CODES.include?(status)
184
+ end
185
+
186
+ def retry_delay(attempt)
187
+ delay = INITIAL_RETRY_DELAY * (2**(attempt - 1))
188
+ delay = [delay, MAX_RETRY_DELAY].min
189
+ jitter = delay * 0.25 * ((rand * 2) - 1)
190
+ delay + jitter
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supermemory
4
+ class Configuration
5
+ attr_accessor :api_key, :base_url, :timeout, :max_retries, :extra_headers
6
+
7
+ def initialize
8
+ @api_key = ENV.fetch("SUPERMEMORY_API_KEY", nil)
9
+ @base_url = ENV.fetch("SUPERMEMORY_BASE_URL", "https://api.supermemory.ai")
10
+ @timeout = 60
11
+ @max_retries = 2
12
+ @extra_headers = {}
13
+ end
14
+ end
15
+
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield(configuration)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supermemory
4
+ class Error < StandardError; end
5
+
6
+ class APIError < Error
7
+ attr_reader :status, :body, :headers
8
+
9
+ def initialize(message = nil, status: nil, body: nil, headers: nil)
10
+ @status = status
11
+ @body = body
12
+ @headers = headers
13
+ super(message || default_message)
14
+ end
15
+
16
+ private
17
+
18
+ def default_message
19
+ "API error (status: #{status})"
20
+ end
21
+ end
22
+
23
+ class BadRequestError < APIError
24
+ def initialize(message = nil, **kwargs)
25
+ super(message, status: 400, **kwargs)
26
+ end
27
+ end
28
+
29
+ class AuthenticationError < APIError
30
+ def initialize(message = nil, **kwargs)
31
+ super(message, status: 401, **kwargs)
32
+ end
33
+ end
34
+
35
+ class PermissionDeniedError < APIError
36
+ def initialize(message = nil, **kwargs)
37
+ super(message, status: 403, **kwargs)
38
+ end
39
+ end
40
+
41
+ class NotFoundError < APIError
42
+ def initialize(message = nil, **kwargs)
43
+ super(message, status: 404, **kwargs)
44
+ end
45
+ end
46
+
47
+ class ConflictError < APIError
48
+ def initialize(message = nil, **kwargs)
49
+ super(message, status: 409, **kwargs)
50
+ end
51
+ end
52
+
53
+ class UnprocessableEntityError < APIError
54
+ def initialize(message = nil, **kwargs)
55
+ super(message, status: 422, **kwargs)
56
+ end
57
+ end
58
+
59
+ class RateLimitError < APIError
60
+ def initialize(message = nil, **kwargs)
61
+ super(message, status: 429, **kwargs)
62
+ end
63
+ end
64
+
65
+ class InternalServerError < APIError
66
+ def initialize(message = nil, **kwargs)
67
+ super(message, status: 500, **kwargs)
68
+ end
69
+ end
70
+
71
+ class APIConnectionError < Error; end
72
+ class APITimeoutError < APIConnectionError; end
73
+
74
+ # @private
75
+ ERROR_MAP = {
76
+ 400 => BadRequestError,
77
+ 401 => AuthenticationError,
78
+ 403 => PermissionDeniedError,
79
+ 404 => NotFoundError,
80
+ 409 => ConflictError,
81
+ 422 => UnprocessableEntityError,
82
+ 429 => RateLimitError
83
+ }.freeze
84
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "supermemory"
4
+
5
+ module Supermemory
6
+ module Integrations
7
+ # Integration with graph-agent (https://github.com/ai-firstly/graph-agent).
8
+ #
9
+ # Provides reusable node functions and a pre-built memory-augmented graph
10
+ # for adding persistent memory to graph-agent workflows.
11
+ #
12
+ # @example Basic usage with pre-built nodes
13
+ # require "supermemory/integrations/graph_agent"
14
+ #
15
+ # nodes = Supermemory::Integrations::GraphAgent::Nodes.new(
16
+ # api_key: ENV["SUPERMEMORY_API_KEY"]
17
+ # )
18
+ #
19
+ # graph = GraphAgent::Graph::StateGraph.new(
20
+ # Supermemory::Integrations::GraphAgent.memory_schema
21
+ # )
22
+ # graph.add_node("recall", nodes.method(:recall_memories))
23
+ # graph.add_node("store", nodes.method(:store_memory))
24
+ module GraphAgent
25
+ # Build a state schema with memory fields
26
+ # @param extra_fields [Hash] Additional field definitions to merge
27
+ # @return [Hash] Schema hash compatible with GraphAgent::Graph::StateGraph
28
+ def self.memory_schema(extra_fields: {})
29
+ {
30
+ messages: { type: Array, reducer: ->(a, b) { a + Array(b) }, default: [] },
31
+ memories: { type: Array, reducer: ->(a, b) { a + Array(b) }, default: [] },
32
+ memory_context: { type: String, default: "" },
33
+ user_id: { type: String, default: "" }
34
+ }.merge(extra_fields)
35
+ end
36
+
37
+ # Pre-built node functions for memory operations
38
+ class Nodes
39
+ attr_reader :client
40
+
41
+ # @param api_key [String] Supermemory API key
42
+ # @param base_url [String, nil] Custom API endpoint
43
+ def initialize(api_key:, base_url: nil)
44
+ @client = Supermemory::Client.new(api_key: api_key, base_url: base_url)
45
+ end
46
+
47
+ # Node: Recall memories based on the last user message.
48
+ # Fetches user profile and relevant memories, stores them in state.
49
+ #
50
+ # @param state [Hash] Current graph state (must contain :messages and :user_id)
51
+ # @param _config [Hash] Graph config (unused but accepted for arity compatibility)
52
+ # @return [Hash] State update with :memories and :memory_context
53
+ def recall_memories(state, _config = nil)
54
+ user_id = state[:user_id]
55
+ return { memories: [], memory_context: "" } unless user_id && !user_id.empty?
56
+
57
+ query = extract_last_user_message(state[:messages])
58
+ return { memories: [], memory_context: "" } unless query
59
+
60
+ result = @client.profile(container_tag: user_id, q: query)
61
+
62
+ static_facts = result.dig("profile", "static") || []
63
+ dynamic_context = result.dig("profile", "dynamic") || []
64
+ search_results = result.dig("searchResults", "results") || []
65
+
66
+ memories = search_results.map { |r| r["memory"] || r["chunk"] }.compact
67
+
68
+ context = build_context(static_facts, dynamic_context, memories)
69
+
70
+ { memories: search_results, memory_context: context }
71
+ rescue => e
72
+ warn "[Supermemory::GraphAgent] Failed to recall memories: #{e.message}"
73
+ { memories: [], memory_context: "" }
74
+ end
75
+
76
+ # Node: Store the latest conversation exchange as a memory.
77
+ #
78
+ # @param state [Hash] Current graph state
79
+ # @param _config [Hash] Graph config
80
+ # @return [Hash] Empty state update
81
+ def store_memory(state, _config = nil)
82
+ user_id = state[:user_id]
83
+ return {} unless user_id && !user_id.empty?
84
+
85
+ messages = state[:messages] || []
86
+ user_msg = extract_last_user_message(messages)
87
+ assistant_msg = extract_last_assistant_message(messages)
88
+
89
+ content = if user_msg && assistant_msg
90
+ "User: #{user_msg}\nAssistant: #{assistant_msg}"
91
+ elsif user_msg
92
+ "User: #{user_msg}"
93
+ end
94
+
95
+ if content
96
+ @client.add(content: content, container_tag: user_id)
97
+ end
98
+
99
+ {}
100
+ rescue => e
101
+ warn "[Supermemory::GraphAgent] Failed to store memory: #{e.message}"
102
+ {}
103
+ end
104
+
105
+ # Node: Search memories with a specific query.
106
+ #
107
+ # @param state [Hash] Current graph state (expects :query or last user message)
108
+ # @param _config [Hash] Graph config
109
+ # @return [Hash] State update with :memories
110
+ def search_memories(state, _config = nil)
111
+ user_id = state[:user_id]
112
+ query = state[:query] || extract_last_user_message(state[:messages])
113
+ return { memories: [] } unless user_id && query
114
+
115
+ result = @client.search.memories(
116
+ q: query,
117
+ container_tag: user_id,
118
+ search_mode: "hybrid",
119
+ limit: 5
120
+ )
121
+
122
+ { memories: result["results"] || [] }
123
+ rescue => e
124
+ warn "[Supermemory::GraphAgent] Failed to search memories: #{e.message}"
125
+ { memories: [] }
126
+ end
127
+
128
+ # Node: Add a specific piece of content to memory.
129
+ #
130
+ # @param state [Hash] Current graph state (expects :memory_content)
131
+ # @param _config [Hash] Graph config
132
+ # @return [Hash] Empty state update
133
+ def add_memory(state, _config = nil)
134
+ user_id = state[:user_id]
135
+ content = state[:memory_content]
136
+ return {} unless user_id && content
137
+
138
+ metadata = state[:memory_metadata] || {}
139
+ @client.add(content: content, container_tag: user_id, metadata: metadata)
140
+
141
+ {}
142
+ rescue => e
143
+ warn "[Supermemory::GraphAgent] Failed to add memory: #{e.message}"
144
+ {}
145
+ end
146
+
147
+ private
148
+
149
+ def extract_last_user_message(messages)
150
+ msg = Array(messages).reverse.find { |m| m[:role] == "user" || m["role"] == "user" }
151
+ msg[:content] || msg["content"] if msg
152
+ end
153
+
154
+ def extract_last_assistant_message(messages)
155
+ msg = Array(messages).reverse.find { |m| m[:role] == "assistant" || m["role"] == "assistant" }
156
+ msg[:content] || msg["content"] if msg
157
+ end
158
+
159
+ def build_context(static_facts, dynamic_context, memories)
160
+ parts = []
161
+ parts << "User Background:\n#{static_facts.map { |f| "- #{f}" }.join("\n")}" if static_facts.any?
162
+ parts << "Recent Context:\n#{dynamic_context.map { |c| "- #{c}" }.join("\n")}" if dynamic_context.any?
163
+ parts << "Relevant Memories:\n#{memories.map { |m| "- #{m}" }.join("\n")}" if memories.any?
164
+ parts.join("\n\n")
165
+ end
166
+ end
167
+
168
+ # Build a complete memory-augmented graph
169
+ #
170
+ # @param api_key [String] Supermemory API key
171
+ # @param llm_node [Proc] A callable that takes (state, config) and returns
172
+ # a state update with at least a new message in :messages
173
+ # @param should_store [Proc, nil] Optional callable (state) -> Boolean
174
+ # to conditionally skip memory storage
175
+ # @param extra_schema [Hash] Additional schema fields
176
+ # @param base_url [String, nil] Custom API endpoint
177
+ # @return [Object] Compiled graph (call .invoke or .stream)
178
+ #
179
+ # @example
180
+ # llm_node = ->(state, _config) {
181
+ # context = state[:memory_context]
182
+ # # ... call your LLM with context ...
183
+ # { messages: [{ role: "assistant", content: response }] }
184
+ # }
185
+ #
186
+ # app = Supermemory::Integrations::GraphAgent.build_memory_graph(
187
+ # api_key: ENV["SUPERMEMORY_API_KEY"],
188
+ # llm_node: llm_node
189
+ # )
190
+ #
191
+ # result = app.invoke(
192
+ # { messages: [{ role: "user", content: "Hi!" }], user_id: "user-123" }
193
+ # )
194
+ def self.build_memory_graph(api_key:, llm_node:, should_store: nil, extra_schema: {}, base_url: nil)
195
+ require "graph_agent"
196
+
197
+ nodes = Nodes.new(api_key: api_key, base_url: base_url)
198
+ schema = memory_schema(extra_fields: extra_schema)
199
+ graph = ::GraphAgent::Graph::StateGraph.new(schema)
200
+
201
+ graph.add_node("recall", nodes.method(:recall_memories))
202
+ graph.add_node("generate", llm_node)
203
+ graph.add_node("store", nodes.method(:store_memory))
204
+
205
+ graph.add_edge(::GraphAgent::START, "recall")
206
+ graph.add_edge("recall", "generate")
207
+
208
+ if should_store
209
+ graph.add_conditional_edges("generate", lambda { |state|
210
+ should_store.call(state) ? "store" : ::GraphAgent::END_NODE.to_s
211
+ })
212
+ else
213
+ graph.add_edge("generate", "store")
214
+ end
215
+
216
+ graph.add_edge("store", ::GraphAgent::END_NODE)
217
+
218
+ graph.compile
219
+ end
220
+ end
221
+ end
222
+ end