membase-strands 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,16 @@
1
+ """Membase memory for Strands Agents: a ``MemoryStore`` plus deliberate search/save/profile tools."""
2
+
3
+ from .store import (
4
+ DEFAULT_RECALL_TIMEOUT,
5
+ MembaseAccessError,
6
+ MembaseMemoryStore,
7
+ MembaseRecallTimeout,
8
+ )
9
+
10
+ __all__ = [
11
+ "DEFAULT_RECALL_TIMEOUT",
12
+ "MembaseAccessError",
13
+ "MembaseMemoryStore",
14
+ "MembaseRecallTimeout",
15
+ ]
16
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,527 @@
1
+ """A Strands ``MemoryStore`` backed by Membase.
2
+
3
+ :class:`MembaseMemoryStore` plugs a person's Membase memory into
4
+ :class:`strands.memory.MemoryManager`:
5
+
6
+ * :meth:`MembaseMemoryStore.search` is what the manager calls before each user turn
7
+ (context injection) and from its built-in ``search_memory`` tool. It is time-boxed
8
+ (8 s by default) because a Membase search is a turn inside the account's own memory
9
+ container, and the first one after a quiet spell can take up to a minute while it
10
+ wakes. A recall that runs out of budget raises, and the manager logs it and carries
11
+ on without memory (fail open); the request keeps running in a daemon thread, so the
12
+ memory is warm for the next turn.
13
+ * :meth:`MembaseMemoryStore.add` / :meth:`MembaseMemoryStore.add_messages` are the
14
+ manager's write sinks. They only exist for the manager when ``capture=True``
15
+ (``writable`` follows ``capture``); the default is off, because Membase saves what
16
+ the user asked to keep, in their words, rather than every turn.
17
+ * :meth:`MembaseMemoryStore.get_tools` gives the model ``membase_search``,
18
+ ``membase_save`` and ``membase_profile``: deliberate calls with the SDK's full
19
+ timeout, whose results cite the Membase memory (container) each passage came from.
20
+
21
+ Failure contract (mirrors the SDK's vended stores): ``search``, ``add`` and
22
+ ``add_messages`` raise on failure, because the manager owns isolation. It logs a failed
23
+ search and continues, surfaces a failed ``add`` as ``AggregateMemoryError`` and retries a
24
+ failed ``add_messages`` batch. Only the model-facing tools turn errors into text.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import concurrent.futures
31
+ import logging
32
+ import os
33
+ import threading
34
+ from collections.abc import Callable, Iterable, Sequence
35
+ from typing import Any, TypeVar
36
+
37
+ from membase import Membase
38
+ from membase.errors import (
39
+ APIConnectionError,
40
+ BadRequestError,
41
+ MembaseError,
42
+ PermissionDeniedError,
43
+ RateLimitError,
44
+ UnprocessableEntityError,
45
+ )
46
+ from strands import tool
47
+ from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
48
+ from strands.types.content import Message
49
+ from strands.types.tools import AgentTool
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ T = TypeVar("T")
54
+
55
+ # The package's own default: it does not depend on which SDK release is installed.
56
+ DEFAULT_BASE_URL = "https://api.app.membase.com"
57
+ DEFAULT_RECALL_TIMEOUT = 8.0
58
+ DEFAULT_MAX_SEARCH_RESULTS = 5
59
+ DEFAULT_DESCRIPTION = (
60
+ "The user's Membase memory: what they told their assistants and the documents their "
61
+ "Memories have read. Each passage names the Memory it came from."
62
+ )
63
+ ALL_TOOLS = ("search", "save", "profile")
64
+
65
+ REVOKED_MESSAGE = (
66
+ "Membase refused this request (403): this Membase key was narrowed or revoked by its owner, "
67
+ "or it does not allow this operation. Ask the owner for access; do not retry with this key."
68
+ )
69
+ TIMEOUT_MESSAGE = (
70
+ "Membase did not answer in time. The memory may still be waking up after a quiet spell; try again in a minute."
71
+ )
72
+
73
+
74
+ class MembaseAccessError(PermissionError):
75
+ """The key was refused (403): its owner narrowed or revoked it. Never retried."""
76
+
77
+
78
+ class MembaseRecallTimeout(TimeoutError):
79
+ """An automatic recall ran past its budget; the manager logs it and continues without memory."""
80
+
81
+
82
+ class MembaseMemoryStore(MemoryStore):
83
+ """One person's Membase memory as a Strands memory store.
84
+
85
+ Membase has no ``user_id``: one API key (or one local directory) is one person's
86
+ account, and the ``MemoryStore`` protocol passes no user or session to ``search``.
87
+ So one store serves one person. For a multi-user app, build the store (and the
88
+ agent's ``MemoryManager``) per user, from that user's own key or directory:
89
+
90
+ .. code-block:: python
91
+
92
+ def agent_for(user):
93
+ store = MembaseMemoryStore(api_key=keys.get(user.id))
94
+ return Agent(memory_manager=MemoryManager(stores=[store], search_tool_config=False))
95
+
96
+ Args:
97
+ client: A ready :class:`membase.Membase` client. Otherwise one is built from
98
+ ``api_key`` / ``base_url`` / ``local`` (or ``MEMBASE_API_KEY``).
99
+ api_key: A Membase developer key (Read, or Read & write to save).
100
+ base_url: Overrides ``https://api.app.membase.com`` (or ``MEMBASE_BASE_URL``).
101
+ local: A directory for local mode (``pip install 'unibaseio-membase[local]'``).
102
+ container: The Memory (container id or name) to search and to write captured
103
+ turns to. Omit to search every Memory the key reaches; writes then need it
104
+ when the key reaches more than one.
105
+ name: Store name the manager and its tools use. Defaults to ``"membase"``.
106
+ description: Shown to the model in the manager's search tool description.
107
+ max_search_results: Default passages per search when the caller passes none.
108
+ recall_timeout: Budget in seconds for :meth:`search` (the manager's automatic
109
+ injection and its ``search_memory`` tool). ``None`` waits for the SDK's own
110
+ timeout. Defaults to 8 s.
111
+ capture: Let the manager write to Membase: makes the store ``writable`` so the
112
+ manager's ``add_memory`` tool and automatic ``extraction`` can use
113
+ :meth:`add` / :meth:`add_messages`. Defaults to ``False``.
114
+ extraction: The manager's automatic-extraction config (``True`` or an
115
+ ``ExtractionConfig``); requires ``capture=True``. Without a custom extractor,
116
+ the manager hands raw turns to :meth:`add_messages`, which saves them as one
117
+ note for Membase to learn from.
118
+ capture_roles: Which messages :meth:`add_messages` saves. Defaults to the user's
119
+ own words only, so assistant answers are not learned as facts.
120
+ tools: Which model-facing tools :meth:`get_tools` returns, from
121
+ ``("search", "save", "profile")``. Defaults to all three; drop ``"save"`` for
122
+ a Read key, ``"profile"`` when the owner did not share the profile.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ *,
128
+ client: Membase | None = None,
129
+ api_key: str | None = None,
130
+ base_url: str | None = None,
131
+ local: str | bool | None = None,
132
+ container: str | None = None,
133
+ name: str = "membase",
134
+ description: str | None = DEFAULT_DESCRIPTION,
135
+ max_search_results: int | None = None,
136
+ recall_timeout: float | None = DEFAULT_RECALL_TIMEOUT,
137
+ capture: bool = False,
138
+ extraction: Any = None,
139
+ capture_roles: Sequence[str] = ("user",),
140
+ tools: Iterable[str] = ALL_TOOLS,
141
+ ) -> None:
142
+ """Build the store. No network call happens here."""
143
+ if extraction and not capture:
144
+ raise ValueError("MembaseMemoryStore: extraction writes to Membase, so it needs capture=True")
145
+ if recall_timeout is not None and recall_timeout <= 0:
146
+ raise ValueError("MembaseMemoryStore: recall_timeout must be positive or None")
147
+ tool_names = tuple(tools)
148
+ unknown = sorted(set(tool_names) - set(ALL_TOOLS))
149
+ if unknown:
150
+ raise ValueError(f"MembaseMemoryStore: unknown tools {unknown}; choose from {ALL_TOOLS}")
151
+
152
+ if client is None:
153
+ base_url = base_url or os.environ.get("MEMBASE_BASE_URL") or DEFAULT_BASE_URL
154
+ client = Membase(api_key=api_key, base_url=base_url, local=local)
155
+ self._owns_client = True
156
+ else:
157
+ self._owns_client = False
158
+ self.client = client
159
+
160
+ # MemoryStore protocol attributes.
161
+ self.name = name
162
+ self.description = description
163
+ self.max_search_results = max_search_results
164
+ self.writable = capture
165
+ self.extraction = extraction
166
+
167
+ self.container = container
168
+ self.recall_timeout = recall_timeout
169
+ self.capture = capture
170
+ self.capture_roles = tuple(capture_roles)
171
+ self.tool_names = tool_names
172
+
173
+ self._container_ids: dict[str, str] = {}
174
+ self._recall_inflight: concurrent.futures.Future[Any] | None = None
175
+ self._lock = threading.Lock()
176
+
177
+ # ------------------------------------------------------------------ search
178
+
179
+ async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
180
+ """Passages on ``query``, most relevant first, each citing its Membase memory.
181
+
182
+ Time-boxed by ``recall_timeout`` because the manager calls it on its own before a
183
+ user turn. Raises :class:`MembaseRecallTimeout` when the budget runs out (the
184
+ manager logs it and continues without memory) and :class:`MembaseAccessError` on
185
+ a 403. A Memory that could not answer comes back as an entry saying so, not as
186
+ silence.
187
+ """
188
+ if not query or not query.strip():
189
+ return []
190
+ limit = options.get("max_search_results") if options else None
191
+ if limit is None:
192
+ limit = self.max_search_results or DEFAULT_MAX_SEARCH_RESULTS
193
+
194
+ def call() -> list[MemoryEntry]:
195
+ return _to_entries(self._search_raw(query, limit))
196
+
197
+ if self.recall_timeout is None:
198
+ return await asyncio.to_thread(_translate_403, call)
199
+ return await self._time_boxed(call)
200
+
201
+ async def _time_boxed(self, call: Callable[[], list[MemoryEntry]]) -> list[MemoryEntry]:
202
+ with self._lock:
203
+ pending = self._recall_inflight
204
+ if pending is not None and not pending.done():
205
+ logger.info("store=<%s> | previous Membase recall still waking the memory; skipping", self.name)
206
+ raise MembaseRecallTimeout(
207
+ "Membase recall skipped: the previous one is still waiting for the memory to wake"
208
+ )
209
+ future: concurrent.futures.Future[list[MemoryEntry]] = concurrent.futures.Future()
210
+ self._recall_inflight = future
211
+
212
+ def run() -> None:
213
+ if not future.set_running_or_notify_cancel():
214
+ return
215
+ try:
216
+ future.set_result(_translate_403(call))
217
+ except BaseException as error: # noqa: BLE001 - handed to the awaiting coroutine
218
+ future.set_exception(error)
219
+
220
+ # A daemon thread, not the loop's executor: Strands runs a sync ``agent(...)`` call
221
+ # under ``asyncio.run``, which waits for executor threads on exit, so a request left
222
+ # behind there would hold the turn hostage. A daemon thread is simply abandoned.
223
+ threading.Thread(target=run, name="membase-recall", daemon=True).start()
224
+ try:
225
+ return await asyncio.wait_for(asyncio.shield(asyncio.wrap_future(future)), self.recall_timeout)
226
+ except asyncio.TimeoutError:
227
+ future.add_done_callback(_drain)
228
+ logger.warning(
229
+ "store=<%s>, budget=<%ss> | Membase recall ran out of time (memory may be waking up); "
230
+ "continuing without memory",
231
+ self.name,
232
+ self.recall_timeout,
233
+ )
234
+ raise MembaseRecallTimeout(
235
+ f"Membase recall took longer than {self.recall_timeout}s; continuing without memory"
236
+ ) from None
237
+
238
+ def _search_raw(self, query: str, limit: int | None, container: str | None = None) -> dict[str, Any]:
239
+ target = container if container is not None else self.container
240
+ container_id = self._resolve_container(target) if target else None
241
+ return self.client.search(query, container=container_id, limit=limit)
242
+
243
+ # ------------------------------------------------------------------ writes
244
+
245
+ async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
246
+ """Save one note to Membase. Only reachable through the manager with ``capture=True``.
247
+
248
+ ``metadata`` may carry ``title`` and ``static`` (a standing fact for the profile);
249
+ other keys are not sent, since Membase does not filter on metadata.
250
+ """
251
+ self._require_capture("add")
252
+ if not content or not content.strip():
253
+ raise ValueError("MembaseMemoryStore: content must not be empty")
254
+ meta = metadata or {}
255
+ static = bool(meta.get("static", False))
256
+ title = str(meta.get("title") or "")
257
+ return await asyncio.to_thread(_translate_403, lambda: self._save(content, None, static, title))
258
+
259
+ async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
260
+ """Save a batch of turns as one note for Membase to learn from (``capture=True`` only).
261
+
262
+ Keeps the text of the roles in ``capture_roles`` (the user's own words by default)
263
+ and skips the batch when nothing is left. Errors propagate so the manager retries
264
+ the batch; a retry can save the same note twice.
265
+ """
266
+ del context
267
+ self._require_capture("add_messages")
268
+ lines: list[str] = []
269
+ for message in messages:
270
+ if message.get("role") not in self.capture_roles:
271
+ continue
272
+ text = "\n".join(
273
+ block["text"] for block in message.get("content", []) if isinstance(block, dict) and block.get("text")
274
+ ).strip()
275
+ if text:
276
+ lines.append(f"{message['role']}: {text}" if len(self.capture_roles) > 1 else text)
277
+ if not lines:
278
+ return None
279
+ note = "\n\n".join(lines)
280
+ return await asyncio.to_thread(
281
+ _translate_403, lambda: self._save(note, None, False, "Conversation with a Strands agent")
282
+ )
283
+
284
+ def _require_capture(self, method: str) -> None:
285
+ if not self.capture:
286
+ raise ValueError(
287
+ f"MembaseMemoryStore.{method}: capture is off. Writes are deliberate by default: "
288
+ "let the model call membase_save, or build the store with capture=True."
289
+ )
290
+
291
+ def _save(self, content: str, container: str | None, static: bool, title: str) -> dict[str, Any]:
292
+ target = container if container is not None else self.container
293
+ container_id = None if static or not target else self._resolve_container(target)
294
+ return self.client.memories.add(content, container=container_id, static=static, title=title)
295
+
296
+ # ------------------------------------------------------------------ containers
297
+
298
+ def _resolve_container(self, value: str) -> str:
299
+ """A container id for ``value`` (an id or a Memory's name), from ``containers.list``."""
300
+ cached = self._container_ids.get(value)
301
+ if cached:
302
+ return cached
303
+ containers = self.client.containers.list().get("containers", [])
304
+ for c in containers:
305
+ if c.get("id") == value:
306
+ self._container_ids[value] = value
307
+ return value
308
+ wanted = value.strip().casefold()
309
+ for c in containers:
310
+ if str(c.get("name", "")).strip().casefold() == wanted:
311
+ self._container_ids[value] = c["id"]
312
+ return c["id"]
313
+ names = ", ".join(repr(c.get("name")) for c in containers) or "none"
314
+ raise ValueError(f"No Membase memory named {value!r} is in this key's reach (available: {names})")
315
+
316
+ # ------------------------------------------------------------------ tools
317
+
318
+ def get_tools(self) -> list[AgentTool]:
319
+ """``membase_search`` / ``membase_save`` / ``membase_profile`` for deliberate calls.
320
+
321
+ These use the SDK's full timeout (a cold start can take up to a minute) and turn
322
+ errors into text the model can relay.
323
+ """
324
+ store = self
325
+ tools: list[AgentTool] = []
326
+
327
+ if "search" in self.tool_names:
328
+
329
+ @tool(
330
+ name="membase_search",
331
+ description=(
332
+ "Search the user's Membase memory for what they told their assistants before and "
333
+ "what their Memories have read. Each passage names the Memory it came from; cite it. "
334
+ "Use when the answer may depend on the user's past, preferences or documents."
335
+ ),
336
+ )
337
+ async def membase_search(query: str, container: str | None = None, limit: int | None = None) -> str:
338
+ """Search Membase.
339
+
340
+ Args:
341
+ query: What to look for, in plain words.
342
+ container: Optional name (or id) of one Memory to search; omit to search all.
343
+ limit: Optional maximum number of passages (1-50).
344
+ """
345
+ return await _tool_call(lambda: _format_search(store._search_raw(query, limit, container), query))
346
+
347
+ tools.append(membase_search)
348
+
349
+ if "save" in self.tool_names:
350
+
351
+ @tool(
352
+ name="membase_save",
353
+ description=(
354
+ "Save something to the user's Membase memory. Only when the user asked you to remember "
355
+ "it or plainly meant it to be kept; save their words, not your summary of the chat. "
356
+ "Set standing_fact=true for a lasting fact about the user (goes to their profile). "
357
+ "If the user has several Memories, name the one to save to."
358
+ ),
359
+ )
360
+ async def membase_save(content: str, container: str | None = None, standing_fact: bool = False) -> str:
361
+ """Save to Membase.
362
+
363
+ Args:
364
+ content: What to save, in the user's words.
365
+ container: Name (or id) of the Memory to save to; needed when there are several.
366
+ standing_fact: True for a lasting fact about the user, kept in their profile.
367
+ """
368
+ if not content or not content.strip():
369
+ return "Nothing to save: content is empty."
370
+
371
+ def save() -> str:
372
+ try:
373
+ return _format_saved(store._save(content, container, standing_fact, ""))
374
+ except BadRequestError as error:
375
+ if standing_fact or container or store.container:
376
+ raise
377
+ names = [str(c.get("name")) for c in store.client.containers.list().get("containers", [])]
378
+ return (
379
+ f"Membase needs to know which Memory to save to ({error.message}). "
380
+ f"Ask the user, then call membase_save again with container set to one of: {names}"
381
+ )
382
+
383
+ return await _tool_call(save)
384
+
385
+ tools.append(membase_save)
386
+
387
+ if "profile" in self.tool_names:
388
+
389
+ @tool(
390
+ name="membase_profile",
391
+ description=(
392
+ "Read the user's Membase profile: standing facts about who they are and recent changes. "
393
+ "Call once near the start of a conversation, optionally with a topic."
394
+ ),
395
+ )
396
+ async def membase_profile(topic: str | None = None) -> str:
397
+ """Read the Membase profile.
398
+
399
+ Args:
400
+ topic: Optional topic to pull relevant profile facts for.
401
+ """
402
+ return await _tool_call(lambda: _format_profile(store.client.profile(q=topic or None)))
403
+
404
+ tools.append(membase_profile)
405
+
406
+ return tools
407
+
408
+ def close(self) -> None:
409
+ """Close the client if this store built it."""
410
+ if self._owns_client:
411
+ self.client.close()
412
+
413
+
414
+ # ---------------------------------------------------------------------- helpers
415
+
416
+
417
+ def _drain(future: concurrent.futures.Future[Any]) -> None:
418
+ """Log the outcome of a recall nobody waited for (and keep its exception retrieved)."""
419
+ if future.cancelled():
420
+ return
421
+ error = future.exception()
422
+ if error is not None:
423
+ logger.info("reason=<%s> | late Membase recall failed", error)
424
+ else:
425
+ logger.info("late Membase recall finished; the memory is awake for the next turn")
426
+
427
+
428
+ def _translate_403(call: Callable[[], T]) -> T:
429
+ try:
430
+ return call()
431
+ except PermissionDeniedError as error:
432
+ raise MembaseAccessError(f"{REVOKED_MESSAGE} ({error.message})") from error
433
+
434
+
435
+ def _to_entries(payload: dict[str, Any]) -> list[MemoryEntry]:
436
+ entries: list[MemoryEntry] = []
437
+ for hit in payload.get("results") or []:
438
+ content = str(hit.get("content") or "").strip()
439
+ if not content:
440
+ continue
441
+ name = hit.get("container_name") or "Membase"
442
+ metadata = {k: v for k, v in hit.items() if k != "content" and v is not None}
443
+ entries.append(MemoryEntry(content=f"[{name}] {content}", metadata=metadata or None))
444
+ for c in _unavailable(payload):
445
+ logger.warning("container=<%s>, reason=<%s> | Membase memory could not answer", c["name"], c["error"])
446
+ entries.append(
447
+ MemoryEntry(
448
+ content=f"[{c['name']}] This Membase memory could not answer the search: {c['error']}",
449
+ metadata={"container_name": c["name"], "container_id": c["id"], "error": c["error"]},
450
+ )
451
+ )
452
+ return entries
453
+
454
+
455
+ def _unavailable(payload: dict[str, Any]) -> list[dict[str, str]]:
456
+ out = []
457
+ for c in payload.get("containers") or []:
458
+ if c.get("error"):
459
+ out.append(
460
+ {"id": str(c.get("id") or ""), "name": str(c.get("name") or c.get("id")), "error": str(c["error"])}
461
+ )
462
+ return out
463
+
464
+
465
+ def _format_search(payload: dict[str, Any], query: str) -> str:
466
+ hits = [h for h in payload.get("results") or [] if str(h.get("content") or "").strip()]
467
+ lines = [
468
+ f"{i}. [{h.get('container_name') or 'Membase'}] {str(h['content']).strip()}" for i, h in enumerate(hits, 1)
469
+ ]
470
+ errors = [f"- Memory '{c['name']}' could not answer: {c['error']}" for c in _unavailable(payload)]
471
+ if not lines and not errors:
472
+ return f"No Membase memory matched {query!r}."
473
+ out = []
474
+ if lines:
475
+ out.append("Membase passages (cite the Memory in brackets):\n" + "\n".join(lines))
476
+ if errors:
477
+ out.append("Not searched, so missing results are not evidence of absence:\n" + "\n".join(errors))
478
+ return "\n\n".join(out)
479
+
480
+
481
+ def _format_saved(result: dict[str, Any] | None) -> str:
482
+ result = result or {}
483
+ if result.get("static"):
484
+ return "Saved to the user's Membase profile as a standing fact."
485
+ return (
486
+ "Saved to Membase. It is queued for learning and shows up in search once the Memory has "
487
+ f"learned it (memory_id={result.get('memory_id')}, status={result.get('status', 'queued')})."
488
+ )
489
+
490
+
491
+ def _format_profile(result: dict[str, Any]) -> str:
492
+ sections = []
493
+ for key, title in (("static", "Standing facts"), ("dynamic", "Recently changed"), ("results", "On this topic")):
494
+ items = [_profile_item(x) for x in result.get(key) or []]
495
+ items = [x for x in items if x]
496
+ if items:
497
+ sections.append(f"{title}:\n" + "\n".join(f"- {x}" for x in items))
498
+ return "\n\n".join(sections) or "The Membase profile is empty."
499
+
500
+
501
+ def _profile_item(item: Any) -> str:
502
+ if isinstance(item, dict):
503
+ return str(item.get("content") or item.get("fact") or item.get("text") or "").strip()
504
+ return str(item).strip()
505
+
506
+
507
+ async def _tool_call(call: Callable[[], str]) -> str:
508
+ """Run a blocking SDK call for a tool and turn Membase errors into a sentence."""
509
+ try:
510
+ return await asyncio.to_thread(call)
511
+ except PermissionDeniedError:
512
+ return REVOKED_MESSAGE
513
+ except APIConnectionError: # includes APITimeoutError
514
+ return TIMEOUT_MESSAGE
515
+ except BadRequestError as error:
516
+ return f"Membase rejected the request: {error.message}"
517
+ except UnprocessableEntityError as error:
518
+ return (
519
+ "The user's Membase memory cannot run right now (no working model or agent container; "
520
+ f"the owner can fix it under AI Setup): {error.message}"
521
+ )
522
+ except RateLimitError:
523
+ return "Membase is busy for this account right now (rate limited); try again shortly."
524
+ except MembaseError as error:
525
+ return f"Membase error: {error}"
526
+ except ValueError as error: # an unknown Memory name
527
+ return str(error)
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.5
2
+ Name: membase-strands
3
+ Version: 0.1.0
4
+ Summary: Membase memory for Strands Agents: a MemoryStore with time-boxed recall and deliberate search/save/profile tools.
5
+ Project-URL: Homepage, https://membase.com
6
+ Project-URL: Documentation, https://github.com/unibaseio/membase/tree/main/integrations/strands#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,long-term-memory,membase,memory,strands,strands-agents
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: strands-agents>=1.45.0
23
+ Requires-Dist: unibaseio-membase>=0.3.2
24
+ Provides-Extra: local
25
+ Requires-Dist: unibaseio-membase[local]>=0.3.2; extra == 'local'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # membase-strands
29
+
30
+ [Membase](https://membase.com) memory for [Strands Agents](https://strandsagents.com) (Python).
31
+
32
+ `MembaseMemoryStore` implements Strands' `MemoryStore` protocol, so it plugs into
33
+ `strands.memory.MemoryManager` like the other memory stores:
34
+
35
+ - **Recall before each user turn.** The manager's context injection calls `search`, and the
36
+ passages it returns are folded into that model call. Each one names the Membase memory
37
+ (container) it came from: `[Project decisions] We chose Postgres for the ledger.`
38
+ - **Time-boxed, fail-open recall.** A Membase search runs inside the account's own memory
39
+ container, and the first one after a quiet spell takes longer while it wakes. The
40
+ automatic recall gets 8 seconds by default (`recall_timeout`). If it runs out, the manager
41
+ logs it and the turn goes ahead without memory. The request keeps running in the
42
+ background, so the memory is awake for the next turn. While it is still running, later
43
+ recalls skip immediately instead of piling up.
44
+ - **Deliberate writes.** `get_tools()` gives the model `membase_search`, `membase_save` and
45
+ `membase_profile`. Saving is a tool the model calls when the user asked it to remember
46
+ something. Saving every turn automatically (`capture=True`) is opt-in.
47
+
48
+ ```bash
49
+ pip install membase-strands
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ Create a developer key in the Membase app under **Connect › Developer keys**. Use
55
+ *Read & write* if the agent should save, and tick the profile if it should read it.
56
+
57
+ ```python
58
+ from strands import Agent
59
+ from strands.memory import MemoryManager
60
+ from membase_strands import MembaseMemoryStore
61
+
62
+ store = MembaseMemoryStore(api_key="mbk_...") # or set MEMBASE_API_KEY
63
+
64
+ agent = Agent(
65
+ memory_manager=MemoryManager(
66
+ stores=[store],
67
+ search_tool_config=False, # the model gets membase_search instead (see below)
68
+ ),
69
+ )
70
+
71
+ agent("Which database did we pick for the ledger?")
72
+ # Before the model call, the manager searches Membase and injects:
73
+ # <memory>
74
+ # <entry source="membase">[Project decisions] We chose Postgres for the ledger.</entry>
75
+ # </memory>
76
+
77
+ agent("Remember that we moved the launch to May.")
78
+ # The model calls membase_save(content="We moved the launch to May.", container="Project decisions")
79
+ ```
80
+
81
+ ### Why `search_tool_config=False`
82
+
83
+ The manager's built-in `search_memory` tool calls the same time-boxed `search` as injection.
84
+ On a cold start it would give up after 8 seconds and show the model an empty list. The
85
+ store's own `membase_search` tool waits for the SDK's full timeout (90 s), so a cold start
86
+ gets an answer. It also lets the model pick one Memory by name, and it says when a Memory
87
+ could not answer. You can keep both tools if you prefer.
88
+
89
+ ## Tools
90
+
91
+ | Tool | What it does |
92
+ |---|---|
93
+ | `membase_search(query, container?, limit?)` | Searches every Memory in reach, or one Memory by name or id. Each passage is cited as `[Memory name] …`. A Memory that could not answer is listed as such, so it is not mistaken for "no memory". |
94
+ | `membase_save(content, container?, standing_fact?)` | Saves a note in the user's words. `standing_fact=true` records a lasting fact in the user's profile. If the key reaches several Memories and none is named, the tool returns their names so the model can ask which one. |
95
+ | `membase_profile(topic?)` | Reads the user's standing facts and recent changes. This needs the owner to have ticked the profile for the key. |
96
+
97
+ Pick the tools with `tools=("search", "profile")`. For example, drop `"save"` for a Read key.
98
+ There is no delete or forget tool, and the store never confirms a destructive operation for
99
+ the user.
100
+
101
+ ## Options
102
+
103
+ ```python
104
+ MembaseMemoryStore(
105
+ client=None, # a ready membase.Membase client, or:
106
+ api_key=None, # MEMBASE_API_KEY
107
+ base_url=None, # MEMBASE_BASE_URL, default https://api.app.membase.com
108
+ local=None, # a directory for local mode (pip install 'membase-strands[local]')
109
+ container=None, # one Memory (name or id) to search and write to; default: all in reach
110
+ name="membase", # store name in the manager
111
+ max_search_results=None, # passages per search when the caller passes none (default 5)
112
+ recall_timeout=8.0, # budget for the automatic recall; None = SDK timeout
113
+ capture=False, # let the manager write (add_memory tool, extraction)
114
+ extraction=None, # the manager's extraction config; needs capture=True
115
+ capture_roles=("user",), # which messages captured turns keep
116
+ tools=("search", "save", "profile"),
117
+ )
118
+ ```
119
+
120
+ ### Automatic capture (opt-in)
121
+
122
+ By default the store is not `writable`, so the manager never writes to Membase. The only way
123
+ to save is the model calling `membase_save` when the user asks. Membase learns from what it
124
+ is given, and a transcript of every turn would teach it the assistant's own answers as facts.
125
+
126
+ To capture anyway:
127
+
128
+ ```python
129
+ store = MembaseMemoryStore(container="Conversations", capture=True, extraction=True)
130
+ agent = Agent(memory_manager=MemoryManager(stores=[store], search_tool_config=False))
131
+ ```
132
+
133
+ With `extraction=True` the manager hands batches of turns to `add_messages`, by default every
134
+ 5 turns and at the end of each synchronous `agent(...)` call. The store saves the user's
135
+ messages from the batch as one note in `container`, and Membase learns from it in the
136
+ background. `capture_roles=("user", "assistant")` keeps both sides. `capture=True` also lets
137
+ you enable the manager's own `add_memory` tool (`MemoryManager(..., add_tool_config=True)`).
138
+ A retried batch can be saved twice.
139
+
140
+ ## One store per person
141
+
142
+ Membase has no `user_id`. One key, or one local directory, is one person's account. The
143
+ `MemoryStore` protocol does not pass a user or session to `search` either. So a store built
144
+ from a single key suits **a personal agent**. In a multi-user app, build the store and the
145
+ agent per user, from that user's own key or directory, and never route one user's turn
146
+ through another user's key:
147
+
148
+ ```python
149
+ def agent_for(user) -> Agent:
150
+ store = MembaseMemoryStore(api_key=keys.get(user.id)) # that user's key
151
+ # or: MembaseMemoryStore(local=f"/data/membase/{user.id}") # one directory per user
152
+ return Agent(memory_manager=MemoryManager(stores=[store], search_tool_config=False))
153
+ ```
154
+
155
+ ## Errors
156
+
157
+ - **403** means the key's owner narrowed or revoked it. Automatic recall raises
158
+ `MembaseAccessError`; the manager logs it and continues without memory. The tools return
159
+ "this Membase key was narrowed or revoked…" so the model can tell the user. Nothing retries
160
+ with that key.
161
+ - **A Memory that could not answer** (no working model, dormant) is reported. Recall returns
162
+ an entry that says so, and `membase_search` lists it under "Not searched".
163
+ - **A recall that runs out of time** raises `MembaseRecallTimeout` (a `TimeoutError`); the
164
+ manager logs it and continues. The tools use the SDK timeout and, if that runs out too,
165
+ return a "try again in a minute" sentence.
166
+ - Captured writes (`add`, `add_messages`) raise on failure so the manager can report or retry
167
+ them. The tools turn errors into sentences.
168
+
169
+ The key is never logged.
170
+
171
+ ## Compatibility
172
+
173
+ Python 3.10+, `strands-agents>=1.45.0` (the first release with `strands.memory`) and
174
+ `unibaseio-membase>=0.3.2`. The offline tests pass on strands-agents 1.45.0, 1.50.0, 1.55.0
175
+ and 1.61.1.
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ uv venv && uv pip install -e . pytest pytest-asyncio ruff
181
+ .venv/bin/python -m pytest -q # offline: real Strands Agent, Membase SDK on httpx.MockTransport
182
+ MEMBASE_API_KEY=mbk_... .venv/bin/python examples/live_smoke.py
183
+ uv build
184
+ ```
185
+
186
+ MIT licence.
@@ -0,0 +1,7 @@
1
+ membase_strands/__init__.py,sha256=kLU8OKgG1g_larSC4YN5yPZdEjNVol9Mgzchy9eH6lo,375
2
+ membase_strands/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ membase_strands/store.py,sha256=hW1ibz84z13zbeY0b35PprvUGVy_TWd1RW1JDGyEg48,24260
4
+ membase_strands-0.1.0.dist-info/METADATA,sha256=QgwOq3gN73Gwu6U1KZELlMpE725xkAUXcJ8F9BPS8XQ,8907
5
+ membase_strands-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ membase_strands-0.1.0.dist-info/licenses/LICENSE,sha256=SWhYL0rjdALC-gYSOEAX-Z4LpxatwZx1kP6XwuOTSzs,1064
7
+ membase_strands-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.