Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/python-sdk/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ questions:
- How does Ed25519 authentication work in the MemWal Python SDK?
answer: >-
The MemWal Python SDK API reference documents all methods on MemWal and MemWalSync
including remember, recall, analyze, ask, restore, health, and lower-level manual methods.
including remember, recall, analyze, ask, restore, list_namespaces, health, and lower-level manual methods.
It also covers result dataclasses, exception hierarchy, middleware wrappers, utility
functions for delegate key derivation, and the Ed25519 request signing protocol.
---
Expand Down Expand Up @@ -183,6 +183,37 @@ RestoreResult(restored: int, skipped: int, total: int, namespace: str, owner: st

`truncated=true` is known-retryable-incomplete (this call's `limit`, or a still-expandable sidecar candidate fetch); `truncated=false` is not proof the sidecar saw every onchain blob (WALM-451 `sourceCapped`).

### `list_namespaces(cursor=None, limit=None) -> NamespacesResult`

List the namespaces this account holds memories in. Returns metadata only, with no blob fetch or decryption.

Recall needs a namespace to search, so an agent on an unfamiliar account would otherwise have to guess names or fall back to `"default"`. Namespaces are flat and exact-match: to work with a prefix such as `proj/`, filter the names client-side and recall each one.

- `cursor`: the previous page's `next_cursor`, to continue a walk or poll for namespaces changed since then
- `limit`: page size; the relayer defaults to `100` and clamps to `500`

```python
NamespacesResult(
namespaces: list[NamespaceSummary], # NamespaceSummary(id, name, memory_count, storage_used, updated_at)
next_cursor: str | None,
has_more: bool,
snapshot_version: int,
)
```

Paginate on `has_more`, not on page length. The relayer clamps `limit`, so a caller asking for more than the cap gets exactly the cap back.

```python
cursor = None
while True:
page = await memwal.list_namespaces(cursor=cursor)
for ns in page.namespaces:
print(ns.name, ns.memory_count)
cursor = page.next_cursor
if not page.has_more:
break
```

### `health() -> HealthResult`

Check relayer health. No authentication — a successful response confirms the relayer is reachable, not that your `key`/`account_id` are valid. A signed call (e.g. `remember()`, `recall()`) can still fail with `401` immediately after a passing `health()`. Raises `MemWalError` on non-200.
Expand Down
10 changes: 9 additions & 1 deletion docs/python-sdk/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,21 @@ questions:
- What changes were made in memwal 0.1.4?
- Where can I find the release history for the Walrus Memory Python SDK?
answer: >-
The latest Python SDK release is 0.1.10. `restore()` results include `failed` (default `0`) for permanent decrypt/UTF-8 failures instead of folding them into `skipped`. 0.1.9 reports HTTP 503 as a retryable upstream outage instead of a credential failure, rejects empty `remember_bulk_async` batches and misaligned relayer `job_ids`, aligns restore `truncated` docs with WALM-431 retryable semantics, and warns when `server_url` uses plaintext HTTP on a non-localhost host without logging URL credentials.
The latest Python SDK release is 0.1.11. It adds `list_namespaces()` so an agent can discover which namespaces hold memories instead of guessing. 0.1.10 adds `failed` to `restore()` results for permanent decrypt/UTF-8 failures instead of folding them into `skipped`. 0.1.9 reports HTTP 503 as a retryable upstream outage instead of a credential failure, rejects empty `remember_bulk_async` batches and misaligned relayer `job_ids`, aligns restore `truncated` docs with WALM-431 retryable semantics, and warns when `server_url` uses plaintext HTTP on a non-localhost host without logging URL credentials.
---

Track what's new, changed, and fixed in `memwal` (Python).

For the latest version, see the [PyPI project page](https://pypi.org/project/memwal/).

## 0.1.11

This release adds `list_namespaces()` for namespace discovery.

### Added

- `list_namespaces(cursor=None, limit=None)` lists the namespaces that hold memories (name, `memory_count`, `storage_used`, `updated_at`), so an agent can discover namespaces instead of guessing. Metadata only; no decryption. Paginate on `has_more`. `MemWalSync` and the mock clients have it too.

## 0.1.10

This release adds `failed` on `restore()` results for permanent decrypt and UTF-8 failures.
Expand Down
6 changes: 6 additions & 0 deletions packages/python-sdk-memwal/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# memwal

## 0.1.11

### Added

- `list_namespaces(cursor=None, limit=None)` lists the namespaces that hold memories (name, `memory_count`, `storage_used`, `updated_at`), so an agent can discover namespaces instead of guessing. Metadata only; no decryption. Paginate on `has_more`. `MemWalSync` and the mock clients have it too.

## 0.1.10

### Added
Expand Down
3 changes: 2 additions & 1 deletion packages/python-sdk-memwal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async def test_memory_flow():
assert "dark mode" in result.results[0].text
```

The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, `forget(blob_id)`, and `clear(namespace)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)`, and `clear(namespace)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.

### Context Manager

Expand Down Expand Up @@ -206,6 +206,7 @@ Create a new async client.
| `await analyze(text, namespace?)` | Extract and store facts |
| `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
| `await restore(namespace, limit?)` | Restore a namespace |
| `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
| `await health()` | Check server health |
| `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
| `await recall_manual(opts)` | Search with pre-computed vector |
Expand Down
6 changes: 5 additions & 1 deletion packages/python-sdk-memwal/memwal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
EmbedResult,
HealthResult,
MemWalConfig,
NamespacesResult,
NamespaceSummary,
RecallManualHit,
RecallManualOptions,
RecallManualResult,
Expand Down Expand Up @@ -114,6 +116,8 @@
"AnalyzedFact",
"HealthResult",
"RestoreResult",
"NamespaceSummary",
"NamespacesResult",
"ScoringWeights",
"RememberManualOptions",
"RememberManualResult",
Expand All @@ -122,4 +126,4 @@
"RecallManualResult",
]

__version__ = "0.1.10"
__version__ = "0.1.11"
117 changes: 116 additions & 1 deletion packages/python-sdk-memwal/memwal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
from urllib.parse import ParseResult, urlparse
from urllib.parse import ParseResult, urlencode, urlparse

import httpx
import nacl.signing
Expand All @@ -50,6 +50,8 @@
EmbedResult,
HealthResult,
MemWalConfig,
NamespacesResult,
NamespaceSummary,
RecallManualHit,
RecallManualOptions,
RecallManualResult,
Expand Down Expand Up @@ -289,6 +291,8 @@ def __init__(self, config: MemWalConfig) -> None:
self._session_build_task: Optional[asyncio.Task[str]] = None
self._relayer_version_metadata: Optional[Dict[str, Any]] = None
self._compatibility_lock: Optional[asyncio.Lock] = None
self._owner_address: Optional[str] = None
self._owner_task: Optional[asyncio.Task[str]] = None
# Preserve a generated key across an ambiguous transport failure. A
# subsequent identical call then collapses onto the accepted paid job.
self._pending_remember_keys: Dict[str, str] = {}
Expand Down Expand Up @@ -962,6 +966,72 @@ async def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
failed=data.get("failed", 0),
)

async def list_namespaces(
self,
cursor: Optional[str] = None,
limit: Optional[int] = None,
) -> NamespacesResult:
"""List the namespaces this account holds memories in.

Recall needs a namespace to search. Without this, an agent on an
unfamiliar account has to guess names or fall back to ``"default"``.
Returns metadata only: no blob fetch, no decryption.

Namespaces are flat and exact-match. To work with a prefix such as
``proj/``, filter the names here and recall each one.

Paginate on ``has_more``, NOT page length: the relayer clamps
``limit``, so asking for more than the cap returns exactly the cap.

Example::

cursor = None
while True:
page = await memwal.list_namespaces(cursor=cursor)
for ns in page.namespaces:
print(ns.name, ns.memory_count)
cursor = page.next_cursor
if not page.has_more:
break

Args:
cursor: A previous page's ``next_cursor``, to continue a walk or
poll for namespaces changed since then. Opaque; not a
timestamp or a namespace name.
limit: Page size. The relayer defaults to 100 and clamps to 500.

Returns:
:class:`NamespacesResult`.
"""
owner = await self._resolve_owner()

params: Dict[str, str] = {}
if cursor is not None:
params["updated_after"] = cursor
if limit is not None:
params["limit"] = str(limit)
query = urlencode(params)

# The query string is part of the signed path: the relayer verifies
# against `path_and_query`, not `path`.
path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "")
data = await self._signed_request("GET", path, {}, include_seal_session=False)
return NamespacesResult(
namespaces=[
NamespaceSummary(
id=ns["id"],
name=ns["name"],
memory_count=ns["memory_count"],
storage_used=ns["storage_used"],
updated_at=ns["updated_at"],
)
for ns in data["namespaces"]
],
next_cursor=data.get("next_cursor"),
has_more=data["has_more"],
snapshot_version=data["snapshot_version"],
)

async def health(self) -> HealthResult:
"""Check server health. No authentication required.

Expand Down Expand Up @@ -1255,6 +1325,43 @@ async def _build_seal_session(self) -> str:
finally:
self._session_build_task = None

async def _resolve_owner_inner(self) -> str:
# POST /api/stats authenticates with the same delegate scheme and
# returns the owner the relayer resolved from our key. Same approach
# as the TypeScript SDK's resolveOwner().
data = await self._signed_request(
"POST",
"/api/stats",
{"namespace": self._namespace},
include_seal_session=False,
)
owner = data.get("owner")
if not owner:
raise MemWalError(
"Walrus Memory could not resolve this account's owner address "
"(POST /api/stats returned no owner)."
)
self._owner_address = owner
return owner

async def _resolve_owner(self) -> str:
"""Owner address for this account, memoised for the client's life.

The owner-scoped read routes take the address in the path, but the
client is configured with only a delegate key and account id.
"""
if self._owner_address is not None:
return self._owner_address

if self._owner_task is not None:
return await self._owner_task

self._owner_task = asyncio.create_task(self._resolve_owner_inner())
try:
return await self._owner_task
finally:
self._owner_task = None

async def _signed_request(
self,
method: str,
Expand Down Expand Up @@ -1668,6 +1775,14 @@ def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
(matches server + TypeScript SDK)."""
return self._run(self._inner.restore(namespace, limit))

def list_namespaces(
self,
cursor: Optional[str] = None,
limit: Optional[int] = None,
) -> NamespacesResult:
"""Synchronous version of :meth:`MemWal.list_namespaces`."""
return self._run(self._inner.list_namespaces(cursor, limit))

def health(self) -> HealthResult:
"""Synchronous version of :meth:`MemWal.health`."""
return self._run(self._inner.health())
Expand Down
Loading
Loading