membase-sdk 0.1.0__tar.gz

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,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: membase-sdk
3
+ Version: 0.1.0
4
+ Summary: Membase — your memory, from code. The official Python client for the Membase API.
5
+ Author: Unibase
6
+ License: MIT
7
+ Project-URL: Homepage, https://www.app.membase.io
8
+ Project-URL: Documentation, https://www.app.membase.io/skill
9
+ Keywords: membase,memory,ai,agents,mcp
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: httpx<1,>=0.25
18
+
19
+ # membase-sdk
20
+
21
+ Your memory, from code. The official Python client for the [Membase](https://www.app.membase.io) API.
22
+
23
+ ```bash
24
+ pip install membase-sdk
25
+ export MEMBASE_API_KEY="mbk_…" # Connect › Developer keys in the Membase app
26
+ ```
27
+
28
+ ```python
29
+ from membase import Membase
30
+
31
+ client = Membase()
32
+
33
+ client.add("Call notes with Acme: they want SSO before the pilot.", container="mv-…", custom_id="call-2026-09-24")
34
+ hits = client.search("what does Acme need before the pilot", limit=5)
35
+ print(hits["results"][0]["content"])
36
+
37
+ client.profile(q="working hours") # who the user is (needs the profile tick on the key)
38
+ client.containers.list()
39
+ client.documents.list(container="mv-…")
40
+ client.memories.add("The user prefers dark mode.", static=True)
41
+ client.documents.delete("srcitem_…", confirm=True) # Full access; confirm means the person agreed
42
+ ```
43
+
44
+ Every method is one operation of the Membase agent protocol; access level, reach and confirmation
45
+ rules are enforced server-side. Errors are one class per HTTP status (`PermissionDeniedError`,
46
+ `RateLimitError`, …) and carry the API's `code` and `trace_id`. 429 and 5xx answers are retried
47
+ twice with backoff; the default timeout is 90 s, because the first search after a quiet spell
48
+ waits for the user's memory to wake.
49
+
50
+ Docs: the developer documentation's *SDK Quickstart*. Works against a self-hosted Membase too:
51
+ `Membase(base_url="http://localhost:8080")`.
@@ -0,0 +1,33 @@
1
+ # membase-sdk
2
+
3
+ Your memory, from code. The official Python client for the [Membase](https://www.app.membase.io) API.
4
+
5
+ ```bash
6
+ pip install membase-sdk
7
+ export MEMBASE_API_KEY="mbk_…" # Connect › Developer keys in the Membase app
8
+ ```
9
+
10
+ ```python
11
+ from membase import Membase
12
+
13
+ client = Membase()
14
+
15
+ client.add("Call notes with Acme: they want SSO before the pilot.", container="mv-…", custom_id="call-2026-09-24")
16
+ hits = client.search("what does Acme need before the pilot", limit=5)
17
+ print(hits["results"][0]["content"])
18
+
19
+ client.profile(q="working hours") # who the user is (needs the profile tick on the key)
20
+ client.containers.list()
21
+ client.documents.list(container="mv-…")
22
+ client.memories.add("The user prefers dark mode.", static=True)
23
+ client.documents.delete("srcitem_…", confirm=True) # Full access; confirm means the person agreed
24
+ ```
25
+
26
+ Every method is one operation of the Membase agent protocol; access level, reach and confirmation
27
+ rules are enforced server-side. Errors are one class per HTTP status (`PermissionDeniedError`,
28
+ `RateLimitError`, …) and carry the API's `code` and `trace_id`. 429 and 5xx answers are retried
29
+ twice with backoff; the default timeout is 90 s, because the first search after a quiet spell
30
+ waits for the user's memory to wake.
31
+
32
+ Docs: the developer documentation's *SDK Quickstart*. Works against a self-hosted Membase too:
33
+ `Membase(base_url="http://localhost:8080")`.
@@ -0,0 +1,40 @@
1
+ """Membase — your memory, from code. ``pip install membase-sdk``.
2
+
3
+ from membase import Membase
4
+ client = Membase() # MEMBASE_API_KEY
5
+ """
6
+
7
+ from ._version import __version__
8
+ from .client import DEFAULT_BASE_URL, Membase
9
+ from .errors import (
10
+ APIConnectionError,
11
+ APIStatusError,
12
+ APITimeoutError,
13
+ AuthenticationError,
14
+ BadRequestError,
15
+ ConflictError,
16
+ InternalServerError,
17
+ MembaseError,
18
+ NotFoundError,
19
+ PermissionDeniedError,
20
+ RateLimitError,
21
+ UnprocessableEntityError,
22
+ )
23
+
24
+ __all__ = [
25
+ "DEFAULT_BASE_URL",
26
+ "Membase",
27
+ "MembaseError",
28
+ "APIConnectionError",
29
+ "APITimeoutError",
30
+ "APIStatusError",
31
+ "BadRequestError",
32
+ "AuthenticationError",
33
+ "PermissionDeniedError",
34
+ "NotFoundError",
35
+ "ConflictError",
36
+ "UnprocessableEntityError",
37
+ "RateLimitError",
38
+ "InternalServerError",
39
+ "__version__",
40
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,247 @@
1
+ """The Membase client: one developer key, the account's memory.
2
+
3
+ from membase import Membase
4
+
5
+ client = Membase() # MEMBASE_API_KEY, optional MEMBASE_BASE_URL
6
+ client.add("Call notes …", container="mv-…", custom_id="call-1")
7
+ client.search("what did we decide about the ledger")
8
+ client.profile(q="working hours")
9
+
10
+ Every method is one operation of the agent protocol (the REST namespace under
11
+ ``https://api.app.membase.io/v1``); the access level, reach and confirmation rules are enforced
12
+ server-side, so nothing here can do what the key cannot.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import random
19
+ import time
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from ._version import __version__
25
+ from .errors import APIConnectionError, APITimeoutError, error_for
26
+
27
+ DEFAULT_BASE_URL = "https://api.app.membase.io"
28
+ #: A search is a turn inside the user's memory; the first one after a quiet spell can take up
29
+ #: to a minute while the memory wakes, so the default is generous.
30
+ DEFAULT_TIMEOUT = 90.0
31
+ DEFAULT_MAX_RETRIES = 2
32
+ _RETRY_STATUSES = {408, 409, 429}
33
+
34
+
35
+ class Membase:
36
+ """The client. Thread-safe for reads; one instance per process is fine."""
37
+
38
+ def __init__(
39
+ self,
40
+ api_key: str | None = None,
41
+ *,
42
+ base_url: str | None = None,
43
+ timeout: float | None = DEFAULT_TIMEOUT,
44
+ max_retries: int = DEFAULT_MAX_RETRIES,
45
+ http_client: httpx.Client | None = None,
46
+ ) -> None:
47
+ api_key = api_key if api_key is not None else os.environ.get("MEMBASE_API_KEY")
48
+ if not api_key:
49
+ raise ValueError(
50
+ "no API key: pass api_key= or set MEMBASE_API_KEY "
51
+ "(Connect › Developer keys in the Membase app)"
52
+ )
53
+ self.api_key = api_key
54
+ self.base_url = (base_url or os.environ.get("MEMBASE_BASE_URL") or DEFAULT_BASE_URL).rstrip(
55
+ "/"
56
+ )
57
+ self.max_retries = max(0, int(max_retries))
58
+ self._owns_http = http_client is None
59
+ self._http = http_client or httpx.Client(base_url=self.base_url, timeout=timeout)
60
+ self.containers = _Containers(self)
61
+ self.documents = _Documents(self)
62
+ self.memories = _Memories(self)
63
+
64
+ # -- the four verbs most code needs ---------------------------------------------------
65
+
66
+ def add(
67
+ self,
68
+ content: str | None = None,
69
+ *,
70
+ container: str,
71
+ url: str | None = None,
72
+ title: str = "",
73
+ metadata: dict | None = None,
74
+ custom_id: str = "",
75
+ ) -> dict:
76
+ """Hand a document (its text, or a public ``url`` to fetch) to a container. Returns at
77
+ once with ``status: queued`` and a ``document_id``; the container learns it in the
78
+ background — ``documents.get(id)["learned"]`` turns true when it has. The same
79
+ ``custom_id`` again is a no-op, so retries are safe."""
80
+ body: dict[str, Any] = {"container": container, "title": title, "custom_id": custom_id}
81
+ if content is not None:
82
+ body["content"] = content
83
+ if url is not None:
84
+ body["url"] = url
85
+ if metadata is not None:
86
+ body["metadata"] = metadata
87
+ return self._request("POST", "/v1/documents", json=body)
88
+
89
+ def search(self, q: str, *, container: str | None = None, limit: int | None = None) -> dict:
90
+ """Passages the containers hold on ``q``, most relevant first, each naming its
91
+ container. ``container`` omitted searches every container in reach. Retrieval, not an
92
+ answer — ``ask`` is the agent's answer. ``containers[]`` in the result marks any
93
+ container that could not answer yet (still waking)."""
94
+ body: dict[str, Any] = {"q": q}
95
+ if container is not None:
96
+ body["container"] = container
97
+ if limit is not None:
98
+ body["limit"] = limit
99
+ return self._request("POST", "/v1/search", json=body)
100
+
101
+ def profile(self, q: str | None = None) -> dict:
102
+ """Who the user is: ``static`` (standing facts), ``dynamic`` (the most recently
103
+ changed facts) and, with ``q``, ``results`` relevant to the topic. Needs a key minted
104
+ with the profile tick."""
105
+ return self._request("GET", "/v1/profile", params={"q": q} if q else None)
106
+
107
+ def ask(self, message: str, *, model: str | None = None) -> dict:
108
+ """The exposed agent's answer to ``message`` (an agent-endpoint credential)."""
109
+ body: dict[str, Any] = {"message": message}
110
+ if model is not None:
111
+ body["model"] = model
112
+ return self._request("POST", "/v1/ask", json=body)
113
+
114
+ def rules(self) -> dict:
115
+ """The user's standing rules for how their memory is used by this credential."""
116
+ return self._request("GET", "/v1/rules")
117
+
118
+ # -- plumbing -------------------------------------------------------------------------
119
+
120
+ def close(self) -> None:
121
+ if self._owns_http:
122
+ self._http.close()
123
+
124
+ def __enter__(self) -> Membase:
125
+ return self
126
+
127
+ def __exit__(self, *exc: object) -> None:
128
+ self.close()
129
+
130
+ def _request(
131
+ self,
132
+ method: str,
133
+ path: str,
134
+ *,
135
+ json: dict | None = None,
136
+ params: dict | None = None,
137
+ ) -> Any:
138
+ headers = {
139
+ "Authorization": f"Bearer {self.api_key}",
140
+ "Accept": "application/json",
141
+ "User-Agent": f"membase-sdk-python/{__version__}",
142
+ }
143
+ attempt = 0
144
+ while True:
145
+ try:
146
+ r = self._http.request(method, path, json=json, params=params, headers=headers)
147
+ except httpx.TimeoutException as e:
148
+ if attempt < self.max_retries:
149
+ attempt += 1
150
+ time.sleep(_backoff(attempt))
151
+ continue
152
+ raise APITimeoutError(f"{method} {path} timed out") from e
153
+ except httpx.HTTPError as e:
154
+ if attempt < self.max_retries:
155
+ attempt += 1
156
+ time.sleep(_backoff(attempt))
157
+ continue
158
+ raise APIConnectionError(f"{method} {path}: {e}") from e
159
+ if 200 <= r.status_code < 300:
160
+ return r.json() if r.content else None
161
+ if (
162
+ r.status_code in _RETRY_STATUSES or r.status_code >= 500
163
+ ) and attempt < self.max_retries:
164
+ attempt += 1
165
+ time.sleep(_backoff(attempt, r.headers.get("Retry-After")))
166
+ continue
167
+ try:
168
+ body = r.json()
169
+ except ValueError:
170
+ body = {"error": {"message": r.text}}
171
+ raise error_for(r.status_code, body)
172
+
173
+
174
+ def _backoff(attempt: int, retry_after: str | None = None) -> float:
175
+ if retry_after:
176
+ try:
177
+ return min(float(retry_after), 30.0)
178
+ except ValueError:
179
+ pass
180
+ return min(0.5 * (2 ** (attempt - 1)), 8.0) * (0.5 + random.random())
181
+
182
+
183
+ class _Containers:
184
+ def __init__(self, client: Membase) -> None:
185
+ self._c = client
186
+
187
+ def list(self) -> dict:
188
+ """The containers this key may use — every one, for the account's owner."""
189
+ return self._c._request("GET", "/v1/containers")
190
+
191
+
192
+ class _Documents:
193
+ def __init__(self, client: Membase) -> None:
194
+ self._c = client
195
+
196
+ def list(self, *, container: str | None = None) -> dict:
197
+ """The documents the containers in reach have read, newest first, each with
198
+ ``learned``."""
199
+ return self._c._request(
200
+ "GET", "/v1/documents", params={"container": container} if container else None
201
+ )
202
+
203
+ def get(self, document_id: str) -> dict:
204
+ """One document, with whether its container has learned it yet."""
205
+ return self._c._request("GET", f"/v1/documents/{document_id}")
206
+
207
+ def delete(self, document_id: str, *, confirm: bool = False) -> dict:
208
+ """Remove one document everywhere (the file goes to the Files trash). Needs Full
209
+ access and ``confirm=True``; without it the answer is ``status:
210
+ confirmation_required`` with a ``how`` sentence to relay, not an error."""
211
+ return self._c._request(
212
+ "DELETE", f"/v1/documents/{document_id}", params={"confirm": _flag(confirm)}
213
+ )
214
+
215
+
216
+ class _Memories:
217
+ def __init__(self, client: Membase) -> None:
218
+ self._c = client
219
+
220
+ def add(
221
+ self,
222
+ content: str,
223
+ *,
224
+ container: str | None = None,
225
+ static: bool = False,
226
+ title: str = "",
227
+ ) -> dict:
228
+ """Save one fact. ``static=True`` is a standing fact about the user and goes to their
229
+ profile; otherwise a note the container reads. ``container`` may be omitted when
230
+ exactly one is in reach."""
231
+ body: dict[str, Any] = {"content": content, "static": static, "title": title}
232
+ if container is not None:
233
+ body["container"] = container
234
+ return self._c._request("POST", "/v1/memories", json=body)
235
+
236
+ def forget(
237
+ self, memory_id: str, *, container: str | None = None, confirm: bool = False
238
+ ) -> dict:
239
+ """Forget one learned fact. Same confirmation rule as ``documents.delete``."""
240
+ params: dict[str, Any] = {"confirm": _flag(confirm)}
241
+ if container is not None:
242
+ params["container"] = container
243
+ return self._c._request("DELETE", f"/v1/memories/{memory_id}", params=params)
244
+
245
+
246
+ def _flag(v: bool) -> str:
247
+ return "true" if v else "false"
@@ -0,0 +1,106 @@
1
+ """Errors, one class per HTTP status class, all carrying the platform's error envelope.
2
+
3
+ The API answers every failure as ``{"error": {"code", "message", "details", "retryable",
4
+ "trace_id"}}``. The status decides the class (so a caller can ``except RateLimitError``), the
5
+ ``code`` says which rule refused (``unauthorized`` for a container outside the key's reach,
6
+ ``capability_unavailable`` when the account's memory cannot run a turn, …), and ``trace_id`` is
7
+ what to quote when reporting a problem.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ class MembaseError(Exception):
16
+ """Base class of everything this package raises."""
17
+
18
+ def __init__(self, message: str, *, status: int | None = None, body: Any = None) -> None:
19
+ super().__init__(message)
20
+ self.message = message
21
+ self.status = status
22
+ self.body = body
23
+ err = body.get("error") if isinstance(body, dict) else None
24
+ err = err if isinstance(err, dict) else {}
25
+ self.code: str = str(err.get("code") or "")
26
+ self.details: Any = err.get("details")
27
+ self.retryable: bool = bool(err.get("retryable", False))
28
+ self.trace_id: str = str(err.get("trace_id") or "")
29
+
30
+ def __str__(self) -> str: # pragma: no cover - formatting
31
+ bits = [self.message]
32
+ if self.status is not None:
33
+ bits.append(f"[{self.status}{' ' + self.code if self.code else ''}]")
34
+ if self.trace_id:
35
+ bits.append(f"trace_id={self.trace_id}")
36
+ return " ".join(bits)
37
+
38
+
39
+ class APIConnectionError(MembaseError):
40
+ """The request never got an answer (DNS, connection refused, reset)."""
41
+
42
+
43
+ class APITimeoutError(APIConnectionError):
44
+ """The request ran past the client's timeout."""
45
+
46
+
47
+ class APIStatusError(MembaseError):
48
+ """A non-2xx answer. Subclasses per status class below."""
49
+
50
+
51
+ class BadRequestError(APIStatusError):
52
+ """400 — a malformed request: a missing ``q``, both ``content`` and ``url``, an ambiguous
53
+ ``container``."""
54
+
55
+
56
+ class AuthenticationError(APIStatusError):
57
+ """401 — the request carried no bearer at all."""
58
+
59
+
60
+ class PermissionDeniedError(APIStatusError):
61
+ """403 (``code: unauthorized``) — an unknown, expired or revoked key; a container outside
62
+ the key's reach; or a verb above its access level. Rotate the key, switch the memory on under
63
+ Reach on the key's page, or mint a key at a higher level."""
64
+
65
+
66
+ class NotFoundError(APIStatusError):
67
+ """404 — an unknown document or memory id."""
68
+
69
+
70
+ class ConflictError(APIStatusError):
71
+ """409."""
72
+
73
+
74
+ class UnprocessableEntityError(APIStatusError):
75
+ """422 — the account's memory cannot run a turn on this deployment (``capability_unavailable``:
76
+ no agent container, no model), or a body FastAPI could not parse."""
77
+
78
+
79
+ class RateLimitError(APIStatusError):
80
+ """429 — the account's turn budget is spent for now. Retried automatically; retry later."""
81
+
82
+
83
+ class InternalServerError(APIStatusError):
84
+ """5xx."""
85
+
86
+
87
+ _BY_STATUS: dict[int, type[APIStatusError]] = {
88
+ 400: BadRequestError,
89
+ 401: AuthenticationError,
90
+ 403: PermissionDeniedError,
91
+ 404: NotFoundError,
92
+ 409: ConflictError,
93
+ 422: UnprocessableEntityError,
94
+ 429: RateLimitError,
95
+ }
96
+
97
+
98
+ def error_for(status: int, body: Any, fallback: str = "") -> APIStatusError:
99
+ """The exception for a non-2xx answer."""
100
+ err = body.get("error") if isinstance(body, dict) else None
101
+ message = (err or {}).get("message") if isinstance(err, dict) else None
102
+ if not message and isinstance(body, dict) and body.get("detail"):
103
+ message = str(body["detail"]) # FastAPI's own 422 shape
104
+ message = message or fallback or f"HTTP {status}"
105
+ cls = _BY_STATUS.get(status) or (InternalServerError if status >= 500 else APIStatusError)
106
+ return cls(message, status=status, body=body)
File without changes
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: membase-sdk
3
+ Version: 0.1.0
4
+ Summary: Membase — your memory, from code. The official Python client for the Membase API.
5
+ Author: Unibase
6
+ License: MIT
7
+ Project-URL: Homepage, https://www.app.membase.io
8
+ Project-URL: Documentation, https://www.app.membase.io/skill
9
+ Keywords: membase,memory,ai,agents,mcp
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: httpx<1,>=0.25
18
+
19
+ # membase-sdk
20
+
21
+ Your memory, from code. The official Python client for the [Membase](https://www.app.membase.io) API.
22
+
23
+ ```bash
24
+ pip install membase-sdk
25
+ export MEMBASE_API_KEY="mbk_…" # Connect › Developer keys in the Membase app
26
+ ```
27
+
28
+ ```python
29
+ from membase import Membase
30
+
31
+ client = Membase()
32
+
33
+ client.add("Call notes with Acme: they want SSO before the pilot.", container="mv-…", custom_id="call-2026-09-24")
34
+ hits = client.search("what does Acme need before the pilot", limit=5)
35
+ print(hits["results"][0]["content"])
36
+
37
+ client.profile(q="working hours") # who the user is (needs the profile tick on the key)
38
+ client.containers.list()
39
+ client.documents.list(container="mv-…")
40
+ client.memories.add("The user prefers dark mode.", static=True)
41
+ client.documents.delete("srcitem_…", confirm=True) # Full access; confirm means the person agreed
42
+ ```
43
+
44
+ Every method is one operation of the Membase agent protocol; access level, reach and confirmation
45
+ rules are enforced server-side. Errors are one class per HTTP status (`PermissionDeniedError`,
46
+ `RateLimitError`, …) and carry the API's `code` and `trace_id`. 429 and 5xx answers are retried
47
+ twice with backoff; the default timeout is 90 s, because the first search after a quiet spell
48
+ waits for the user's memory to wake.
49
+
50
+ Docs: the developer documentation's *SDK Quickstart*. Works against a self-hosted Membase too:
51
+ `Membase(base_url="http://localhost:8080")`.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ membase/__init__.py
4
+ membase/_version.py
5
+ membase/client.py
6
+ membase/errors.py
7
+ membase/py.typed
8
+ membase_sdk.egg-info/PKG-INFO
9
+ membase_sdk.egg-info/SOURCES.txt
10
+ membase_sdk.egg-info/dependency_links.txt
11
+ membase_sdk.egg-info/requires.txt
12
+ membase_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx<1,>=0.25
@@ -0,0 +1 @@
1
+ membase
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "membase-sdk"
7
+ dynamic = ["version"]
8
+ description = "Membase — your memory, from code. The official Python client for the Membase API."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Unibase" }]
13
+ keywords = ["membase", "memory", "ai", "agents", "mcp"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = ["httpx>=0.25,<1"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://www.app.membase.io"
25
+ Documentation = "https://www.app.membase.io/skill"
26
+
27
+ [tool.setuptools]
28
+ packages = ["membase"]
29
+
30
+ [tool.setuptools.package-data]
31
+ membase = ["py.typed"]
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = { attr = "membase._version.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+