langchain-membase 0.1.0__py3-none-any.whl

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,49 @@
1
+ """Membase long-term memory for LangChain and LangGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from ._common import DEFAULT_RECALL_TIMEOUT, MissingUserIdError
8
+ from .retrievers import MembaseRetriever, MembaseUnavailableError
9
+ from .tools import (
10
+ MembaseProfileTool,
11
+ MembaseSaveTool,
12
+ MembaseSearchTool,
13
+ MembaseToolkit,
14
+ membase_tools,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from .middleware import MembaseRecallMiddleware
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "DEFAULT_RECALL_TIMEOUT",
24
+ "MembaseProfileTool",
25
+ "MembaseRecallMiddleware",
26
+ "MembaseRetriever",
27
+ "MembaseSaveTool",
28
+ "MembaseSearchTool",
29
+ "MembaseToolkit",
30
+ "MembaseUnavailableError",
31
+ "MissingUserIdError",
32
+ "membase_tools",
33
+ "__version__",
34
+ ]
35
+
36
+
37
+ def __getattr__(name: str) -> Any:
38
+ # The middleware needs `langchain` (the `agents` extra); importing the package must not.
39
+ if name == "MembaseRecallMiddleware":
40
+ try:
41
+ from .middleware import MembaseRecallMiddleware
42
+ except ImportError as e:
43
+ raise ImportError(
44
+ "MembaseRecallMiddleware needs LangChain v1: "
45
+ "pip install 'langchain-membase[agents]'"
46
+ ) from e
47
+
48
+ return MembaseRecallMiddleware
49
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,248 @@
1
+ """Shared plumbing: which client serves a call, how errors read, how recall is formatted."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import threading
9
+ from collections.abc import Callable, Mapping
10
+ from pathlib import Path
11
+ from typing import Any, TypeVar
12
+
13
+ from membase import Membase
14
+ from membase.errors import (
15
+ APIConnectionError,
16
+ APITimeoutError,
17
+ MembaseError,
18
+ PermissionDeniedError,
19
+ )
20
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
21
+
22
+ logger = logging.getLogger("langchain_membase")
23
+
24
+ ClientFor = Callable[[str], Membase]
25
+ """Returns the Membase client of one end user: their own key, or their own local directory."""
26
+
27
+ T = TypeVar("T")
28
+
29
+ # The package's own default: it does not depend on which SDK release is installed.
30
+ DEFAULT_BASE_URL = "https://api.app.membase.com"
31
+
32
+ DEFAULT_RECALL_TIMEOUT = 8.0
33
+ """Budget, in seconds, for automatic per-turn recall. Explicit tool calls use the SDK's own."""
34
+
35
+
36
+ class MissingUserIdError(ValueError):
37
+ """``client_for`` is set but the call carried no user id, so no client may serve it."""
38
+
39
+
40
+ def build_client(
41
+ api_key: str | SecretStr | None = None,
42
+ base_url: str | None = None,
43
+ local: str | os.PathLike | bool | None = None,
44
+ ) -> Membase:
45
+ """A Membase client from the usual arguments, falling back to ``MEMBASE_API_KEY``."""
46
+ key = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
47
+ base_url = base_url or os.environ.get("MEMBASE_BASE_URL") or DEFAULT_BASE_URL
48
+ return Membase(api_key=key, base_url=base_url, local=local)
49
+
50
+
51
+ class MembaseClientMixin(BaseModel):
52
+ """Fields every Membase object takes: one ready client, or ``client_for`` for many users."""
53
+
54
+ model_config = ConfigDict(arbitrary_types_allowed=True)
55
+
56
+ client: Membase | None = Field(default=None, exclude=True, repr=False)
57
+ """A ready Membase client. One client serves every call: right for a personal agent."""
58
+ client_for: ClientFor | None = Field(default=None, exclude=True, repr=False)
59
+ """``user_id -> Membase`` for a multi-user app; the user id comes from the run's config."""
60
+ api_key: SecretStr | None = Field(default=None, exclude=True, repr=False)
61
+ """Membase API key; defaults to ``MEMBASE_API_KEY``. Used when no ``client`` is given."""
62
+ base_url: str | None = None
63
+ """API base URL; defaults to ``MEMBASE_BASE_URL`` or ``https://api.app.membase.com``."""
64
+ local: str | Path | bool | None = Field(default=None, exclude=True)
65
+ """Run the Membase API in-process on this directory (``True`` = ``~/.membase``)."""
66
+
67
+ @model_validator(mode="after")
68
+ def _one_way_to_reach_memory(self) -> MembaseClientMixin:
69
+ if self.client_for is not None:
70
+ if self.client is not None or self.api_key is not None or self.local:
71
+ raise ValueError(
72
+ "pass either client_for (one client per user) or client / api_key / local "
73
+ "(one client for every call), not both"
74
+ )
75
+ return self
76
+ if self.client is None:
77
+ self.client = build_client(self.api_key, self.base_url, self.local or None)
78
+ return self
79
+
80
+ def _membase(self, user_id: str | None) -> Membase:
81
+ return resolve_client(self.client, self.client_for, user_id)
82
+
83
+
84
+ def resolve_client(
85
+ client: Membase | None, client_for: ClientFor | None, user_id: str | None
86
+ ) -> Membase:
87
+ """The client for this call. With ``client_for`` a user id is mandatory: no fallback client."""
88
+ if client_for is not None:
89
+ if not user_id:
90
+ raise MissingUserIdError(
91
+ "client_for is set, so every call needs the end user's id: pass "
92
+ "config={'configurable': {'user_id': ...}} (or a LangGraph context with user_id)"
93
+ )
94
+ return client_for(user_id)
95
+ if client is None: # pragma: no cover - the validator always sets one
96
+ raise ValueError("no Membase client configured")
97
+ if user_id:
98
+ _warn_single_client_once()
99
+ return client
100
+
101
+
102
+ _warned_single_client = False
103
+
104
+
105
+ def _warn_single_client_once() -> None:
106
+ global _warned_single_client
107
+ if not _warned_single_client:
108
+ _warned_single_client = True
109
+ logger.warning(
110
+ "a user_id reached langchain-membase but no client_for is set, so one Membase key "
111
+ "serves every user. That suits a personal agent only; a multi-user app must pass "
112
+ "client_for so each person's call uses their own key."
113
+ )
114
+
115
+
116
+ def user_id_from(config: Mapping[str, Any] | None = None, context: Any = None) -> str | None:
117
+ """The end user's id: ``configurable.user_id`` first, then a LangGraph runtime context."""
118
+ configurable = (config or {}).get("configurable") or {}
119
+ uid = configurable.get("user_id")
120
+ if uid in (None, "") and context is None:
121
+ context = _langgraph_context()
122
+ if uid in (None, "") and context is not None:
123
+ uid = (
124
+ context.get("user_id")
125
+ if isinstance(context, Mapping)
126
+ else getattr(context, "user_id", None)
127
+ )
128
+ return str(uid) if uid not in (None, "") else None
129
+
130
+
131
+ def _langgraph_context() -> Any:
132
+ try:
133
+ from langgraph.runtime import get_runtime
134
+ except ImportError:
135
+ return None
136
+ try:
137
+ return get_runtime().context
138
+ except Exception: # noqa: BLE001 - outside a graph run there is simply no context
139
+ return None
140
+
141
+
142
+ def describe_error(e: BaseException) -> str:
143
+ """One actionable sentence for the model and the logs."""
144
+ if isinstance(e, PermissionDeniedError):
145
+ return (
146
+ f"Membase refused this request (403): {e.message}. This Membase key was narrowed or "
147
+ "revoked by its owner (a Memory switched off, an access level too low for this "
148
+ "action, or the key revoked); do not retry with this key."
149
+ )
150
+ if isinstance(e, APITimeoutError):
151
+ return (
152
+ "Membase did not answer in time; the user's memory may still be waking up after a "
153
+ "quiet spell. Try again in a moment."
154
+ )
155
+ if isinstance(e, APIConnectionError):
156
+ return f"Could not reach Membase: {e.message}."
157
+ if isinstance(e, MembaseError):
158
+ return f"Membase error: {e}"
159
+ return f"{type(e).__name__}: {e}"
160
+
161
+
162
+ def unavailable(resp: Mapping[str, Any]) -> list[str]:
163
+ """``name: error`` for every Memory that could not answer a search."""
164
+ out = []
165
+ for c in resp.get("containers") or []:
166
+ if c.get("error"):
167
+ out.append(f"{c.get('name') or c.get('id')}: {c['error']}")
168
+ return out
169
+
170
+
171
+ def format_passages(results: list[Mapping[str, Any]]) -> str:
172
+ """One line per passage, each citing the Memory (container) it came from."""
173
+ lines = []
174
+ for r in results:
175
+ name = r.get("container_name") or r.get("container") or "Membase"
176
+ content = str(r.get("content") or "").strip()
177
+ if content:
178
+ lines.append(f"- [{name}] {content}")
179
+ return "\n".join(lines)
180
+
181
+
182
+ def format_search(resp: Mapping[str, Any]) -> str:
183
+ """Passages with their Memory, plus any Memory that could not answer (never 'no memory')."""
184
+ body = format_passages(resp.get("results") or [])
185
+ failed = unavailable(resp)
186
+ note = ""
187
+ if failed:
188
+ note = "These Memories could not answer, so what they hold is unknown: " + "; ".join(failed)
189
+ if body:
190
+ return body + (f"\n\n{note}" if note else "")
191
+ if note:
192
+ return "No passages came back. " + note
193
+ return "No matching memories."
194
+
195
+
196
+ def call_with_budget(fn: Callable[[], T], timeout: float) -> T:
197
+ """Run ``fn`` but stop waiting after ``timeout`` seconds (raises ``TimeoutError``).
198
+
199
+ The call keeps running on a daemon thread; letting it finish wakes the user's memory, so
200
+ the next turn is fast. A daemon thread never holds up interpreter exit.
201
+ """
202
+ box: dict[str, Any] = {}
203
+ done = threading.Event()
204
+
205
+ def run() -> None:
206
+ try:
207
+ box["value"] = fn()
208
+ except BaseException as e: # noqa: BLE001 - re-raised on the caller's thread
209
+ box["error"] = e
210
+ finally:
211
+ done.set()
212
+
213
+ threading.Thread(target=run, name="membase-recall", daemon=True).start()
214
+ if not done.wait(timeout):
215
+ raise TimeoutError(f"Membase recall took longer than {timeout:g}s")
216
+ if "error" in box:
217
+ raise box["error"]
218
+ return box["value"]
219
+
220
+
221
+ async def acall_with_budget(fn: Callable[[], T], timeout: float) -> T:
222
+ """``call_with_budget`` for an event loop: the blocking SDK call runs off the loop."""
223
+ loop = asyncio.get_running_loop()
224
+ fut: asyncio.Future[T] = loop.create_future()
225
+
226
+ def settle(value: Any = None, error: BaseException | None = None) -> None:
227
+ if fut.done():
228
+ return
229
+ if error is not None:
230
+ fut.set_exception(error)
231
+ else:
232
+ fut.set_result(value)
233
+
234
+ def run() -> None:
235
+ try:
236
+ value, error = fn(), None
237
+ except BaseException as e: # noqa: BLE001 - handed to the awaiting coroutine
238
+ value, error = None, e
239
+ try:
240
+ loop.call_soon_threadsafe(settle, value, error)
241
+ except RuntimeError: # the loop closed while the call was still running
242
+ pass
243
+
244
+ threading.Thread(target=run, name="membase-recall", daemon=True).start()
245
+ try:
246
+ return await asyncio.wait_for(fut, timeout)
247
+ except asyncio.TimeoutError:
248
+ raise TimeoutError(f"Membase recall took longer than {timeout:g}s") from None
@@ -0,0 +1,246 @@
1
+ """MembaseRecallMiddleware: time-boxed recall before the model call of a ``create_agent`` agent.
2
+
3
+ Needs ``langchain>=1.1`` (``pip install 'langchain-membase[agents]'``).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import threading
10
+ from collections import OrderedDict
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
15
+ from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
16
+ from membase import Membase
17
+
18
+ from ._common import (
19
+ DEFAULT_RECALL_TIMEOUT,
20
+ ClientFor,
21
+ acall_with_budget,
22
+ build_client,
23
+ call_with_budget,
24
+ describe_error,
25
+ format_passages,
26
+ logger,
27
+ resolve_client,
28
+ unavailable,
29
+ user_id_from,
30
+ )
31
+
32
+ RECALL_HEADER = (
33
+ "## From the user's memory (Membase)\n"
34
+ "Passages that may be relevant to the user's latest message, each with the Memory it came "
35
+ "from in [brackets]. Use them only if they help, and cite the Memory when you do."
36
+ )
37
+
38
+ _CACHE_SIZE = 512
39
+
40
+
41
+ class MembaseRecallMiddleware(AgentMiddleware):
42
+ """Searches Membase once per user message and adds what it finds to the system message.
43
+
44
+ * Recall is automatic, so it is time-boxed (``timeout``, 8 s by default) and fails open:
45
+ on a timeout, a refused key or any other error it logs and the model runs without memory.
46
+ A search that ran past the budget keeps going in the background, which wakes the user's
47
+ memory for the next turn.
48
+ * The search runs on the first model call after a user message; the model calls that follow
49
+ tool calls in the same turn reuse its result.
50
+ * The recall goes into the request's system message only; it is never written to the
51
+ agent's state or checkpoint.
52
+ * ``capture_turns=True`` (off by default) saves the user's own message(s) of each turn,
53
+ once, as a note in ``container``; assistant replies are never saved. Writing is
54
+ otherwise left to ``MembaseSaveTool``.
55
+
56
+ Multi-user: pass ``client_for``; the user id comes from the run context
57
+ (``context_schema`` with ``user_id``) or ``config["configurable"]["user_id"]``.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ client: Membase | None = None,
63
+ *,
64
+ client_for: ClientFor | None = None,
65
+ api_key: str | None = None,
66
+ base_url: str | None = None,
67
+ local: str | os.PathLike | bool | None = None,
68
+ container: str | None = None,
69
+ k: int | None = 5,
70
+ timeout: float = DEFAULT_RECALL_TIMEOUT,
71
+ capture_turns: bool = False,
72
+ header: str = RECALL_HEADER,
73
+ ) -> None:
74
+ super().__init__()
75
+ if client_for is not None and (client is not None or api_key or local):
76
+ raise ValueError("pass either client_for or client / api_key / local, not both")
77
+ if client_for is None and client is None:
78
+ client = build_client(api_key, base_url, local)
79
+ self.client = client
80
+ self.client_for = client_for
81
+ self.container = container
82
+ self.k = k
83
+ self.timeout = timeout
84
+ self.capture_turns = capture_turns
85
+ self.header = header
86
+ self._cache: OrderedDict[tuple[str, str], str] = OrderedDict()
87
+ self._lock = threading.Lock()
88
+
89
+ # -- model call -------------------------------------------------------------------------
90
+
91
+ def wrap_model_call(
92
+ self,
93
+ request: ModelRequest,
94
+ handler: Callable[[ModelRequest], ModelResponse],
95
+ ) -> ModelResponse:
96
+ key = self._turn_key(request)
97
+ if key is None:
98
+ return handler(request)
99
+ recall = self._cached(key)
100
+ if recall is None:
101
+ recall = self._recall(lambda: self._search(key[0], key[2]))
102
+ self._remember(key, recall)
103
+ return handler(self._with_recall(request, recall))
104
+
105
+ async def awrap_model_call(
106
+ self,
107
+ request: ModelRequest,
108
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
109
+ ) -> ModelResponse:
110
+ key = self._turn_key(request)
111
+ if key is None:
112
+ return await handler(request)
113
+ recall = self._cached(key)
114
+ if recall is None:
115
+ try:
116
+ resp = await acall_with_budget(lambda: self._search(key[0], key[2]), self.timeout)
117
+ recall = self._format(resp)
118
+ except Exception as e: # noqa: BLE001 - automatic recall fails open
119
+ recall = self._failed(e)
120
+ self._remember(key, recall)
121
+ return await handler(self._with_recall(request, recall))
122
+
123
+ # -- optional capture -------------------------------------------------------------------
124
+
125
+ def after_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None:
126
+ if self.capture_turns:
127
+ self._capture(state, runtime)
128
+ return None
129
+
130
+ async def aafter_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None:
131
+ if self.capture_turns:
132
+ self._capture(state, runtime)
133
+ return None
134
+
135
+ def _capture(self, state: Any, runtime: Any) -> None:
136
+ """Save the user's own message(s) of the turn, once. Assistant replies are never saved:
137
+ an AI answer must not be learned as a fact about the user."""
138
+ messages: list[BaseMessage] = list(state.get("messages") or [])
139
+ last_human = _last_index(messages, HumanMessage)
140
+ if last_human is None:
141
+ return
142
+ first = last_human
143
+ while first > 0 and isinstance(messages[first - 1], HumanMessage):
144
+ first -= 1
145
+ turn = messages[first : last_human + 1]
146
+ note = "\n\n".join(t for t in (m.text.strip() for m in turn) if t)
147
+ if not note:
148
+ return
149
+ user_id = _user_id(runtime)
150
+ turn_key = (user_id or "", "captured:" + ",".join(m.id or str(hash(m.text)) for m in turn))
151
+ with self._lock:
152
+ if turn_key in self._cache:
153
+ return
154
+ self._cache[turn_key] = ""
155
+ try:
156
+ client = resolve_client(self.client, self.client_for, user_id)
157
+ except Exception as e: # noqa: BLE001 - capture never breaks the run
158
+ logger.warning("Membase capture skipped: %s", describe_error(e))
159
+ return
160
+
161
+ def save() -> None:
162
+ try:
163
+ client.memories.add(note, container=self.container)
164
+ except Exception as e: # noqa: BLE001 - logged, the run already finished
165
+ logger.warning("Membase capture failed: %s", describe_error(e))
166
+
167
+ # Saving waits on the network (up to the SDK timeout); do not hold the agent's answer.
168
+ threading.Thread(target=save, name="membase-capture", daemon=True).start()
169
+
170
+ # -- helpers ----------------------------------------------------------------------------
171
+
172
+ def _turn_key(self, request: ModelRequest) -> tuple[str, str, str] | None:
173
+ """(user id, message id, query) of the latest user message, or None when there is none."""
174
+ messages = list(request.messages or [])
175
+ idx = _last_index(messages, HumanMessage)
176
+ if idx is None:
177
+ return None
178
+ msg = messages[idx]
179
+ query = msg.text.strip()
180
+ if not query:
181
+ return None
182
+ return (_user_id(request.runtime) or "", msg.id or f"{idx}:{hash(query)}", query)
183
+
184
+ def _search(self, user_id: str, query: str) -> dict[str, Any]:
185
+ client = resolve_client(self.client, self.client_for, user_id or None)
186
+ return client.search(query, container=self.container, limit=self.k)
187
+
188
+ def _recall(self, fn: Callable[[], dict[str, Any]]) -> str:
189
+ try:
190
+ return self._format(call_with_budget(fn, self.timeout))
191
+ except Exception as e: # noqa: BLE001 - automatic recall fails open
192
+ return self._failed(e)
193
+
194
+ def _format(self, resp: dict[str, Any]) -> str:
195
+ failed = unavailable(resp)
196
+ if failed:
197
+ logger.warning("Membase recall: some Memories could not answer: %s", "; ".join(failed))
198
+ return format_passages(resp.get("results") or [])
199
+
200
+ def _failed(self, e: BaseException) -> str:
201
+ logger.warning("Membase recall skipped, continuing without memory: %s", describe_error(e))
202
+ return ""
203
+
204
+ def _cached(self, key: tuple[str, str, str]) -> str | None:
205
+ with self._lock:
206
+ return self._cache.get(key[:2])
207
+
208
+ def _remember(self, key: tuple[str, str, str], recall: str) -> None:
209
+ with self._lock:
210
+ self._cache[key[:2]] = recall
211
+ self._cache.move_to_end(key[:2])
212
+ while len(self._cache) > _CACHE_SIZE:
213
+ self._cache.popitem(last=False)
214
+
215
+ def _with_recall(self, request: ModelRequest, recall: str) -> ModelRequest:
216
+ if not recall:
217
+ return request
218
+ block = f"{self.header}\n{recall}"
219
+ current = request.system_message
220
+ if current is None:
221
+ return request.override(system_message=SystemMessage(content=block))
222
+ if isinstance(current.content, str):
223
+ content: Any = f"{current.content}\n\n{block}" if current.content else block
224
+ else:
225
+ content = [*current.content, {"type": "text", "text": block}]
226
+ return request.override(system_message=SystemMessage(content=content))
227
+
228
+
229
+ def _user_id(runtime: Any) -> str | None:
230
+ """From the run context (``context_schema`` with ``user_id``), else ``configurable``."""
231
+ user_id = user_id_from(None, getattr(runtime, "context", None))
232
+ if user_id is None:
233
+ try:
234
+ from langgraph.config import get_config
235
+
236
+ user_id = user_id_from(get_config())
237
+ except Exception: # noqa: BLE001 - outside a run, or Python 3.10 async
238
+ user_id = None
239
+ return user_id
240
+
241
+
242
+ def _last_index(messages: list[BaseMessage], kind: type[BaseMessage]) -> int | None:
243
+ for i in range(len(messages) - 1, -1, -1):
244
+ if isinstance(messages[i], kind):
245
+ return i
246
+ return None
File without changes
@@ -0,0 +1,129 @@
1
+ """MembaseRetriever: a Membase search as LangChain ``Document``s."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from langchain_core.callbacks import (
8
+ AsyncCallbackManagerForRetrieverRun,
9
+ CallbackManagerForRetrieverRun,
10
+ )
11
+ from langchain_core.documents import Document
12
+ from langchain_core.retrievers import BaseRetriever
13
+ from langchain_core.runnables import RunnableConfig
14
+ from langchain_core.runnables.config import ensure_config, run_in_executor
15
+ from membase.errors import PermissionDeniedError
16
+ from pydantic import Field
17
+
18
+ from ._common import MembaseClientMixin, logger, unavailable, user_id_from
19
+
20
+
21
+ class MembaseUnavailableError(RuntimeError):
22
+ """A search found nothing *and* some Memories could not answer: not evidence of no memory."""
23
+
24
+
25
+ class MembaseRetriever(MembaseClientMixin, BaseRetriever):
26
+ """Passages from the user's Membase memory, most relevant first.
27
+
28
+ Each ``Document`` carries ``container_name`` (the Memory it came from) and ``container``
29
+ (its id) in ``metadata``, so an answer can cite where it came from.
30
+
31
+ Setup:
32
+ ``pip install langchain-membase`` and set ``MEMBASE_API_KEY`` (Connect > Developer keys
33
+ in the Membase app), or pass ``client=Membase(...)``.
34
+
35
+ Instantiate:
36
+ .. code-block:: python
37
+
38
+ from langchain_membase import MembaseRetriever
39
+
40
+ retriever = MembaseRetriever(k=5)
41
+
42
+ Usage:
43
+ .. code-block:: python
44
+
45
+ docs = retriever.invoke("what did we decide about the ledger")
46
+ docs[0].metadata["container_name"] # "Project decisions"
47
+
48
+ Many users:
49
+ .. code-block:: python
50
+
51
+ retriever = MembaseRetriever(client_for=lambda user_id: Membase(api_key=keys[user_id]))
52
+ retriever.invoke("...", config={"configurable": {"user_id": "alice"}})
53
+ """
54
+
55
+ k: int | None = Field(default=None, ge=1, le=50)
56
+ """How many passages in all. Unset lets Membase cover every Memory in reach (at least 8)."""
57
+ container: str | None = None
58
+ """One container (Memory) id to search; unset searches every Memory the key reaches."""
59
+
60
+ def invoke(
61
+ self, input: str, config: RunnableConfig | None = None, **kwargs: Any
62
+ ) -> list[Document]:
63
+ config = ensure_config(config)
64
+ if kwargs.get("user_id") is None:
65
+ kwargs["user_id"] = user_id_from(config)
66
+ return super().invoke(input, config, **kwargs)
67
+
68
+ async def ainvoke(
69
+ self, input: str, config: RunnableConfig | None = None, **kwargs: Any
70
+ ) -> list[Document]:
71
+ config = ensure_config(config)
72
+ if kwargs.get("user_id") is None:
73
+ kwargs["user_id"] = user_id_from(config)
74
+ return await super().ainvoke(input, config, **kwargs)
75
+
76
+ def _get_relevant_documents(
77
+ self,
78
+ query: str,
79
+ *,
80
+ run_manager: CallbackManagerForRetrieverRun,
81
+ k: int | None = None,
82
+ container: str | None = None,
83
+ user_id: str | None = None,
84
+ ) -> list[Document]:
85
+ client = self._membase(user_id)
86
+ try:
87
+ resp = client.search(query, container=container or self.container, limit=k or self.k)
88
+ except PermissionDeniedError as e:
89
+ raise PermissionDeniedError(
90
+ f"{e.message} (this Membase key was narrowed or revoked by its owner; "
91
+ "do not retry with it)",
92
+ status=e.status,
93
+ body=e.body,
94
+ ) from e
95
+ docs = [_document(r) for r in resp.get("results") or [] if r.get("content")]
96
+ failed = unavailable(resp)
97
+ if failed and not docs:
98
+ raise MembaseUnavailableError(
99
+ "no passages came back and these Memories could not answer: " + "; ".join(failed)
100
+ )
101
+ if failed:
102
+ logger.warning("Membase search: some Memories could not answer: %s", "; ".join(failed))
103
+ return docs
104
+
105
+ async def _aget_relevant_documents(
106
+ self,
107
+ query: str,
108
+ *,
109
+ run_manager: AsyncCallbackManagerForRetrieverRun,
110
+ k: int | None = None,
111
+ container: str | None = None,
112
+ user_id: str | None = None,
113
+ ) -> list[Document]:
114
+ # The SDK is synchronous; keep the event loop free while it waits on the network.
115
+ return await run_in_executor(
116
+ None,
117
+ self._get_relevant_documents,
118
+ query,
119
+ run_manager=run_manager.get_sync(),
120
+ k=k,
121
+ container=container,
122
+ user_id=user_id,
123
+ )
124
+
125
+
126
+ def _document(r: dict[str, Any]) -> Document:
127
+ metadata = {key: value for key, value in r.items() if key != "content"}
128
+ metadata.setdefault("container_name", r.get("container_name") or r.get("container"))
129
+ return Document(page_content=str(r["content"]), metadata=metadata)
@@ -0,0 +1,296 @@
1
+ """Agent tools: search, save (only when the user asked) and read the profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Literal
7
+
8
+ from langchain_core.callbacks import CallbackManagerForToolRun
9
+ from langchain_core.runnables import RunnableConfig
10
+ from langchain_core.tools import BaseTool, BaseToolkit, ToolException
11
+ from membase import Membase
12
+ from membase.errors import BadRequestError, MembaseError, PermissionDeniedError
13
+ from pydantic import BaseModel, Field
14
+
15
+ from ._common import (
16
+ ClientFor,
17
+ MembaseClientMixin,
18
+ describe_error,
19
+ format_passages,
20
+ format_search,
21
+ user_id_from,
22
+ )
23
+
24
+
25
+ class _MembaseTool(MembaseClientMixin, BaseTool):
26
+ handle_tool_error: bool = True
27
+ """A Membase failure becomes the tool's answer, so the model can tell the user."""
28
+
29
+ def _client_for_call(self, config: RunnableConfig | None) -> Membase:
30
+ return self._membase(user_id_from(config))
31
+
32
+
33
+ def _container_id(client: Membase, ref: str) -> str:
34
+ """A container id from a name or id the model gave, or a ToolException naming the choices."""
35
+ containers = client.containers.list().get("containers") or []
36
+ for c in containers:
37
+ if c.get("id") == ref:
38
+ return ref
39
+ wanted = ref.strip().casefold()
40
+ matches = [c for c in containers if str(c.get("name") or "").strip().casefold() == wanted]
41
+ if len(matches) == 1:
42
+ return str(matches[0]["id"])
43
+ names = ", ".join(repr(c.get("name")) for c in containers) or "none"
44
+ raise ToolException(f"No single Memory called {ref!r} is in reach. Memories in reach: {names}.")
45
+
46
+
47
+ class MembaseSearchInput(BaseModel):
48
+ query: str = Field(description="What to look for, as a natural-language question or topic.")
49
+ container: str | None = Field(
50
+ default=None,
51
+ description="Name of one Memory to search. Omit to search every Memory in reach.",
52
+ )
53
+
54
+
55
+ class MembaseSearchTool(_MembaseTool):
56
+ """Search the user's Membase memory; each passage names the Memory it came from.
57
+
58
+ The tool answers with text for the model and keeps the raw search response as the
59
+ ``ToolMessage`` artifact.
60
+ """
61
+
62
+ name: str = "membase_search"
63
+ description: str = (
64
+ "Search the user's long-term memory (Membase) for passages relevant to a question: what "
65
+ "they told you before, their projects, decisions, preferences and documents. Each passage "
66
+ "starts with the name of the Memory it came from in [brackets]; cite it in your answer. "
67
+ "The first search after a quiet spell can take up to a minute."
68
+ )
69
+ args_schema: type[BaseModel] = MembaseSearchInput
70
+ response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
71
+
72
+ k: int | None = Field(default=None, ge=1, le=50)
73
+ """How many passages in all; unset lets Membase cover every Memory in reach."""
74
+ container: str | None = None
75
+ """Default container id when the model names none; unset searches every Memory."""
76
+
77
+ def _run(
78
+ self,
79
+ query: str,
80
+ container: str | None = None,
81
+ *,
82
+ config: RunnableConfig,
83
+ run_manager: CallbackManagerForToolRun | None = None,
84
+ ) -> tuple[str, dict[str, Any]]:
85
+ client = self._client_for_call(config)
86
+ try:
87
+ target = _container_id(client, container) if container else self.container
88
+ resp = client.search(query, container=target, limit=self.k)
89
+ except MembaseError as e:
90
+ raise ToolException(describe_error(e)) from e
91
+ return format_search(resp), resp
92
+
93
+
94
+ class MembaseSaveInput(BaseModel):
95
+ content: str = Field(
96
+ description="The fact or note to keep, in the user's own words. One fact per call."
97
+ )
98
+ static: bool = Field(
99
+ default=False,
100
+ description=(
101
+ "True for a lasting fact about the user themself (name, role, a standing "
102
+ "preference): it goes to their profile. False for a note about anything else."
103
+ ),
104
+ )
105
+ container: str | None = Field(
106
+ default=None,
107
+ description="Name of the Memory that should keep a note (ignored when static is true).",
108
+ )
109
+
110
+
111
+ class MembaseSaveTool(_MembaseTool):
112
+ """Save one fact to the user's Membase memory. Writes only when the model calls it.
113
+
114
+ Needs a Read & write key. There is deliberately no delete or forget tool.
115
+ """
116
+
117
+ name: str = "membase_save"
118
+ description: str = (
119
+ "Save one fact or note to the user's long-term memory (Membase). Call this ONLY when the "
120
+ "user asks you to remember something or plainly means for it to be kept; never to log "
121
+ "the conversation. Pass the content in the user's own words. Set static=true for a "
122
+ "lasting fact about the user themself; otherwise it is a note kept by one Memory."
123
+ )
124
+ args_schema: type[BaseModel] = MembaseSaveInput
125
+
126
+ container: str | None = None
127
+ """Container id that keeps notes when the model names none. Needed when the key reaches
128
+ several Memories (unless the model names one)."""
129
+
130
+ def _run(
131
+ self,
132
+ content: str,
133
+ static: bool = False,
134
+ container: str | None = None,
135
+ *,
136
+ config: RunnableConfig,
137
+ run_manager: CallbackManagerForToolRun | None = None,
138
+ ) -> str:
139
+ client = self._client_for_call(config)
140
+ try:
141
+ target = None
142
+ if not static:
143
+ target = _container_id(client, container) if container else self.container
144
+ resp = client.memories.add(content, container=target, static=static)
145
+ except BadRequestError as e:
146
+ if not static and target is None:
147
+ raise ToolException(_which_container(client, e)) from e
148
+ raise ToolException(describe_error(e)) from e
149
+ except MembaseError as e:
150
+ raise ToolException(describe_error(e)) from e
151
+ return _saved(resp or {})
152
+
153
+
154
+ def _which_container(client: Membase, e: BadRequestError) -> str:
155
+ try:
156
+ names = [c.get("name") for c in client.containers.list().get("containers") or []]
157
+ except MembaseError:
158
+ names = []
159
+ listed = ", ".join(repr(n) for n in names) or "unknown"
160
+ return (
161
+ f"{e.message}. Several Memories are in reach; call again with container set to one "
162
+ f"of: {listed}."
163
+ )
164
+
165
+
166
+ def _saved(resp: dict[str, Any]) -> str:
167
+ if resp.get("static"):
168
+ return "Recorded in the user's profile."
169
+ if resp.get("status") == "learned":
170
+ return "Saved and learned; a search can find it now."
171
+ where = resp.get("container_name") or resp.get("container") or "the Memory"
172
+ return (
173
+ f"Saved to {where}. It is learned in the background, so a search may not find it until "
174
+ "that finishes."
175
+ )
176
+
177
+
178
+ class MembaseProfileInput(BaseModel):
179
+ topic: str | None = Field(
180
+ default=None,
181
+ description="Optional current topic, to also get the profile passages relevant to it.",
182
+ )
183
+
184
+
185
+ class MembaseProfileTool(_MembaseTool):
186
+ """Who the user is: standing facts and recently changed ones, from the Membase profile.
187
+
188
+ Only works when the key's owner ticked the profile for it; otherwise Membase answers 403.
189
+ """
190
+
191
+ name: str = "membase_profile"
192
+ description: str = (
193
+ "Read who the user is from their Membase profile: standing facts (name, role, lasting "
194
+ "preferences) and recently changed facts. Call it once near the start of a conversation, "
195
+ "optionally with the current topic."
196
+ )
197
+ args_schema: type[BaseModel] = MembaseProfileInput
198
+
199
+ def _run(
200
+ self,
201
+ topic: str | None = None,
202
+ *,
203
+ config: RunnableConfig,
204
+ run_manager: CallbackManagerForToolRun | None = None,
205
+ ) -> str:
206
+ client = self._client_for_call(config)
207
+ try:
208
+ resp = client.profile(q=topic or None)
209
+ except PermissionDeniedError as e:
210
+ raise ToolException(
211
+ describe_error(e) + " (A key reads the profile only if its owner ticked it.)"
212
+ ) from e
213
+ except MembaseError as e:
214
+ raise ToolException(describe_error(e)) from e
215
+ return _format_profile(resp or {})
216
+
217
+
218
+ def _text(item: Any) -> str:
219
+ if isinstance(item, str):
220
+ return item.strip()
221
+ if isinstance(item, dict):
222
+ return str(item.get("content") or item.get("description") or item.get("text") or "").strip()
223
+ return str(item).strip()
224
+
225
+
226
+ def _format_profile(resp: dict[str, Any]) -> str:
227
+ parts = []
228
+ static = [t for t in map(_text, resp.get("static") or []) if t]
229
+ dynamic = [t for t in map(_text, resp.get("dynamic") or []) if t]
230
+ if static:
231
+ parts.append("Standing facts:\n" + "\n".join(f"- {t}" for t in static))
232
+ if dynamic:
233
+ parts.append("Recently changed:\n" + "\n".join(f"- {t}" for t in dynamic))
234
+ if resp.get("results"):
235
+ passages = format_passages(resp["results"])
236
+ if passages:
237
+ parts.append("Relevant to the topic:\n" + passages)
238
+ return "\n\n".join(parts) or "The profile is empty."
239
+
240
+
241
+ class MembaseToolkit(MembaseClientMixin, BaseToolkit):
242
+ """The Membase tools, sharing one client (or one ``client_for``).
243
+
244
+ .. code-block:: python
245
+
246
+ from langchain_membase import MembaseToolkit
247
+
248
+ tools = MembaseToolkit().get_tools() # MEMBASE_API_KEY from the environment
249
+ """
250
+
251
+ container: str | None = None
252
+ """Default container id for search and save when the model names none."""
253
+ k: int | None = Field(default=None, ge=1, le=50)
254
+ """How many passages a search returns in all."""
255
+ save: bool = True
256
+ """Include ``membase_save`` (a Read key cannot write anyway)."""
257
+ profile: bool = True
258
+ """Include ``membase_profile`` (only useful if the key's owner ticked the profile)."""
259
+
260
+ def get_tools(self) -> list[BaseTool]:
261
+ shared: dict[str, Any] = {"client": self.client, "client_for": self.client_for}
262
+ tools: list[BaseTool] = [MembaseSearchTool(**shared, container=self.container, k=self.k)]
263
+ if self.save:
264
+ tools.append(MembaseSaveTool(**shared, container=self.container))
265
+ if self.profile:
266
+ tools.append(MembaseProfileTool(**shared))
267
+ return tools
268
+
269
+
270
+ def membase_tools(
271
+ client: Membase | None = None,
272
+ *,
273
+ client_for: ClientFor | None = None,
274
+ api_key: str | None = None,
275
+ base_url: str | None = None,
276
+ local: str | os.PathLike | bool | None = None,
277
+ container: str | None = None,
278
+ k: int | None = None,
279
+ save: bool = True,
280
+ profile: bool = True,
281
+ ) -> list[BaseTool]:
282
+ """``MembaseToolkit(...).get_tools()``: search, save and profile sharing one client.
283
+
284
+ ``save=False`` leaves the write tool out; ``profile=False`` leaves the profile out.
285
+ """
286
+ return MembaseToolkit(
287
+ client=client,
288
+ client_for=client_for,
289
+ api_key=api_key,
290
+ base_url=base_url,
291
+ local=os.fspath(local) if isinstance(local, os.PathLike) else local,
292
+ container=container,
293
+ k=k,
294
+ save=save,
295
+ profile=profile,
296
+ ).get_tools()
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-membase
3
+ Version: 0.1.0
4
+ Summary: Membase long-term memory for LangChain and LangGraph: a retriever, agent tools and a recall middleware.
5
+ Project-URL: Homepage, https://membase.com
6
+ Project-URL: Documentation, https://github.com/unibaseio/membase/tree/main/integrations/langchain#readme
7
+ Project-URL: Repository, https://github.com/unibaseio/membase
8
+ Project-URL: Issues, https://github.com/unibaseio/membase/issues
9
+ Author: Unibase
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,langchain,langgraph,membase,memory,retriever
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: langchain-core<2,>=1.0
23
+ Requires-Dist: unibaseio-membase>=0.3.2
24
+ Provides-Extra: agents
25
+ Requires-Dist: langchain<2,>=1.1; extra == 'agents'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # langchain-membase
29
+
30
+ [Membase](https://membase.com) long-term memory for LangChain and LangGraph (Python).
31
+
32
+ Membase keeps a person's memory in their own account: named **Memories** (containers) that
33
+ learn from their notes, files and conversations, plus a **profile** of standing facts. This
34
+ package gives a LangChain app three ways to use it:
35
+
36
+ | Piece | LangChain type | What it does |
37
+ |---|---|---|
38
+ | `MembaseRetriever` | `BaseRetriever` | a Membase search as `Document`s, each citing its Memory in `metadata["container_name"]` |
39
+ | `MembaseToolkit` (`membase_tools()`): `MembaseSearchTool`, `MembaseSaveTool`, `MembaseProfileTool` | `BaseToolkit` / `BaseTool` | agent tools: search, save (only when the user asks), read the profile |
40
+ | `MembaseRecallMiddleware` | LangChain v1 `AgentMiddleware` | time-boxed recall added to the system message before the model call of a `create_agent` agent |
41
+
42
+ There is deliberately **no delete or forget tool**, and the package never confirms a
43
+ destructive action. It also does not implement LangGraph's `BaseStore`: Membase is a memory
44
+ that learns, not a key-value store (there is no get-by-key), so it is offered as retrieval and
45
+ tools instead.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install langchain-membase # retriever + tools (needs langchain-core)
51
+ pip install 'langchain-membase[agents]' # + the create_agent middleware (needs langchain>=1.1)
52
+ ```
53
+
54
+ Python 3.10+. Get a key in the Membase app under **Connect › Developer keys** and set it:
55
+
56
+ ```bash
57
+ export MEMBASE_API_KEY=mbk_...
58
+ ```
59
+
60
+ A **Read** key can search and read the profile (if its owner ticked it); saving needs a
61
+ **Read & write** key. One key is one person's account.
62
+
63
+ ## Retriever
64
+
65
+ ```python
66
+ from langchain_membase import MembaseRetriever
67
+
68
+ retriever = MembaseRetriever(k=5) # MEMBASE_API_KEY from the environment
69
+ docs = retriever.invoke("what did we decide about the ledger")
70
+ for d in docs:
71
+ print(d.metadata["container_name"], "·", d.page_content)
72
+ ```
73
+
74
+ * `k` (1–50) is the number of passages in all; unset lets Membase cover every Memory in reach.
75
+ * `container="mv-…"` limits the search to one Memory (its id from `client.containers.list()`).
76
+ Both can also be passed per call: `retriever.invoke(q, k=3, container="mv-…")`.
77
+ * If no passage comes back **and** some Memory could not answer (no model, dormant), the
78
+ retriever raises `MembaseUnavailableError` instead of returning `[]`: that is not evidence of
79
+ "no memory". When some Memories answered, it returns their passages and logs the rest.
80
+ * A `403` (`membase.PermissionDeniedError`) means the key's owner narrowed or revoked it; do
81
+ not retry with that key.
82
+
83
+ It works anywhere a retriever does, e.g. in an LCEL chain:
84
+
85
+ ```python
86
+ from langchain_core.prompts import ChatPromptTemplate
87
+ from langchain_core.runnables import RunnablePassthrough
88
+
89
+ def cite(docs):
90
+ return "\n".join(f"[{d.metadata['container_name']}] {d.page_content}" for d in docs)
91
+
92
+ prompt = ChatPromptTemplate.from_template("Memory:\n{memory}\n\nQuestion: {question}")
93
+ chain = {"memory": retriever | cite, "question": RunnablePassthrough()} | prompt | llm
94
+ ```
95
+
96
+ ## Agent tools
97
+
98
+ ```python
99
+ from langchain.agents import create_agent
100
+ from langchain_membase import MembaseToolkit
101
+
102
+ agent = create_agent("openai:gpt-5", tools=MembaseToolkit().get_tools()) # or membase_tools()
103
+ agent.invoke({"messages": [{"role": "user", "content": "What did we pick for the ledger?"}]})
104
+ ```
105
+
106
+ | Tool | Arguments | Notes |
107
+ |---|---|---|
108
+ | `membase_search` | `query`, optional `container` (a Memory's **name**) | passages as `- [Memory name] text`; the raw response is the `ToolMessage` artifact; Memories that could not answer are reported |
109
+ | `membase_save` | `content` (the user's words), `static`, optional `container` | the description tells the model to save **only when the user asks**; `static=true` records a standing fact in the profile; with several Memories in reach it needs a `container` (or a default `container=` id on the tool) and says which names exist |
110
+ | `membase_profile` | optional `topic` | standing and recently changed facts; only for a key whose owner ticked the profile |
111
+
112
+ `MembaseToolkit(save=False, profile=False)` (or `membase_tools(client, save=False, profile=False)`) leaves tools out; `container=` and `k=` set
113
+ defaults. Errors come back as the tool's answer (`handle_tool_error=True`), so the model can
114
+ tell the user, e.g. "this Membase key was narrowed or revoked".
115
+
116
+ Explicit tool calls use the SDK's own timeout (90 s): the first search after a quiet spell
117
+ takes longer while the user's memory wakes.
118
+
119
+ ## Recall middleware (LangChain v1)
120
+
121
+ ```python
122
+ from langchain.agents import create_agent
123
+ from langchain_membase import MembaseRecallMiddleware, membase_tools
124
+
125
+ agent = create_agent(
126
+ "openai:gpt-5",
127
+ tools=membase_tools(),
128
+ system_prompt="You are a helpful assistant.",
129
+ middleware=[MembaseRecallMiddleware(k=5, timeout=8.0)],
130
+ )
131
+ ```
132
+
133
+ Before the model call, the middleware searches Membase for the latest user message and appends
134
+ the passages (each with its Memory) to the system message of that request.
135
+
136
+ * **Time-boxed and fail-open**: it waits at most `timeout` seconds (8 by default). On a timeout,
137
+ a `403` or any other error it logs a warning on the `langchain_membase` logger and the model
138
+ runs without memory. A search that ran past the budget finishes in the background, which
139
+ wakes the memory for the next turn.
140
+ * **Once per user message**: model calls that follow tool calls in the same turn reuse the
141
+ result.
142
+ * **Nothing is persisted**: the recall lives in the model request only, never in the agent's
143
+ state or checkpoint.
144
+ * **No automatic writes by default**. `capture_turns=True` (opt-in) saves the user's own
145
+ message(s) of each turn, once, as a note in `container=`, in the background. Assistant
146
+ replies are never saved, so an AI answer is not learned as a fact about the user.
147
+
148
+ ## Many users
149
+
150
+ A Membase key belongs to one person, so a multi-user app holds one key (or one local
151
+ directory) **per end user** and passes `client_for`, a function from user id to that user's
152
+ client. Every class and `membase_tools()` accept it. The user id is read from the run:
153
+
154
+ ```python
155
+ from functools import lru_cache
156
+ from membase import Membase
157
+ from langchain_membase import MembaseRecallMiddleware, MembaseRetriever, membase_tools
158
+
159
+ @lru_cache(maxsize=1024) # reuse each user's client (and its connection pool)
160
+ def client_for(user_id: str) -> Membase:
161
+ return Membase(api_key=load_user_key(user_id)) # that user's key, never another's
162
+
163
+ retriever = MembaseRetriever(client_for=client_for)
164
+ retriever.invoke("...", config={"configurable": {"user_id": "alice"}})
165
+
166
+ agent = create_agent(
167
+ model,
168
+ tools=membase_tools(client_for=client_for),
169
+ middleware=[MembaseRecallMiddleware(client_for=client_for)],
170
+ )
171
+ agent.invoke({"messages": [...]}, config={"configurable": {"user_id": "alice"}})
172
+ ```
173
+
174
+ The user id is taken from `config["configurable"]["user_id"]`, or from a LangGraph run
175
+ context with a `user_id` field (`create_agent(..., context_schema=Ctx)` and
176
+ `agent.invoke(..., context=Ctx(user_id="alice"))`). With `client_for` set, a call that carries
177
+ no user id raises `MissingUserIdError` (the middleware logs it and skips recall); it never
178
+ falls back to another client. `client_for` and `client`/`api_key`/`local` are mutually
179
+ exclusive.
180
+
181
+ **Without `client_for`, one client serves every call.** That suits a personal agent (your own
182
+ key, your own memory), not a multi-user app.
183
+
184
+ Local mode keeps each person in their own directory:
185
+ `client_for=lambda uid: Membase(local=f"/data/membase/{uid}")`
186
+ (`pip install 'unibaseio-membase[local]'`, Python 3.12+, and an LLM key such as
187
+ `OPENAI_API_KEY`).
188
+
189
+ ## Configuration
190
+
191
+ Every class takes `client=Membase(...)`, or `api_key=` / `base_url=` / `local=`; otherwise
192
+ `MEMBASE_API_KEY` and `MEMBASE_BASE_URL` come from the environment. The API host defaults to
193
+ `https://api.app.membase.com`.
194
+
195
+ ## Development
196
+
197
+ ```bash
198
+ uv venv .venv && uv pip install --python .venv/bin/python -e . \
199
+ 'langchain>=1.1' langgraph langchain-tests pytest pytest-asyncio ruff
200
+ .venv/bin/python -m pytest # offline: the real SDK over httpx.MockTransport
201
+ MEMBASE_API_KEY=mbk_... .venv/bin/python examples/live_smoke.py # against the real API
202
+ uv build
203
+ ```
204
+
205
+ The offline suite includes LangChain's standard tests (`langchain-tests`) for the retriever
206
+ and the three tools, and runs the tools and the middleware inside a real `create_agent`
207
+ (LangGraph) graph with a scripted chat model.
208
+
209
+ ## Licence
210
+
211
+ MIT
@@ -0,0 +1,10 @@
1
+ langchain_membase/__init__.py,sha256=RcFEEfKDkPjThqeQ6k3w0DTG739KJ3BuHg7etOkWtsY,1349
2
+ langchain_membase/_common.py,sha256=nWphcafsGFULz9gPepxd2iGQUlmTNTIM-9pYH5J3fhY,9530
3
+ langchain_membase/middleware.py,sha256=gFpnF18k3ZT9Nc6e0kDtwgUQpSFpdhBCVDlO6MBVhyo,9946
4
+ langchain_membase/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ langchain_membase/retrievers.py,sha256=_O27S1Z8cND7BHzOcqoa1Yc2SQAT4IO7QXs90NIHHc8,4784
6
+ langchain_membase/tools.py,sha256=xxX_-Ww_ZxjUZxBLDkAPgdHgL6-1-Ju4OX82lAjMTjk,11036
7
+ langchain_membase-0.1.0.dist-info/METADATA,sha256=fKh7QfxWmK51LDpRSalonF55iPohBl5ZmcK8t5Q-e5A,9741
8
+ langchain_membase-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ langchain_membase-0.1.0.dist-info/licenses/LICENSE,sha256=SWhYL0rjdALC-gYSOEAX-Z4LpxatwZx1kP6XwuOTSzs,1064
10
+ langchain_membase-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Unibase
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.