memwal 0.1.10.dev0__tar.gz → 0.1.11.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/CHANGELOG.md +6 -0
  2. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/PKG-INFO +3 -2
  3. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/README.md +2 -1
  4. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/__init__.py +5 -1
  5. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/client.py +116 -1
  6. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/mock.py +93 -1
  7. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/types.py +28 -0
  8. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/pyproject.toml +1 -1
  9. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_integration.py +24 -0
  10. memwal-0.1.11.dev0/tests/test_list_namespaces.py +220 -0
  11. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_mock.py +102 -0
  12. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/.gitignore +0 -0
  13. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/.env.example +0 -0
  14. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/.gitignore +0 -0
  15. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/async_remember_demo.py +0 -0
  16. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/interactive_demo.py +0 -0
  17. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/verify_credentials.py +0 -0
  18. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/compatibility.py +0 -0
  19. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/middleware.py +0 -0
  20. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/utils.py +0 -0
  21. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  22. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/run_tests.py +0 -0
  23. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/__init__.py +0 -0
  24. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_auth_rejected_message.py +0 -0
  25. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_client.py +0 -0
  26. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_env_presets.py +0 -0
  27. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_middleware.py +0 -0
  28. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_normalize_server_url.py +0 -0
  29. {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_signing.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # memwal
2
2
 
3
+ ## 0.1.11
4
+
5
+ ### Added
6
+
7
+ - `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.
8
+
3
9
  ## 0.1.10
4
10
 
5
11
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.10.dev0
3
+ Version: 0.1.11.dev0
4
4
  Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memory.walrus.xyz
6
6
  Project-URL: Documentation, https://memory.walrus.xyz
@@ -139,7 +139,7 @@ async def test_memory_flow():
139
139
  assert "dark mode" in result.results[0].text
140
140
  ```
141
141
 
142
- 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.
142
+ 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.
143
143
 
144
144
  ### Context Manager
145
145
 
@@ -245,6 +245,7 @@ Create a new async client.
245
245
  | `await analyze(text, namespace?)` | Extract and store facts |
246
246
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
247
247
  | `await restore(namespace, limit?)` | Restore a namespace |
248
+ | `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
248
249
  | `await health()` | Check server health |
249
250
  | `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
250
251
  | `await recall_manual(opts)` | Search with pre-computed vector |
@@ -100,7 +100,7 @@ async def test_memory_flow():
100
100
  assert "dark mode" in result.results[0].text
101
101
  ```
102
102
 
103
- 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.
103
+ 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.
104
104
 
105
105
  ### Context Manager
106
106
 
@@ -206,6 +206,7 @@ Create a new async client.
206
206
  | `await analyze(text, namespace?)` | Extract and store facts |
207
207
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
208
208
  | `await restore(namespace, limit?)` | Restore a namespace |
209
+ | `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
209
210
  | `await health()` | Check server health |
210
211
  | `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
211
212
  | `await recall_manual(opts)` | Search with pre-computed vector |
@@ -44,6 +44,8 @@ from .types import (
44
44
  EmbedResult,
45
45
  HealthResult,
46
46
  MemWalConfig,
47
+ NamespacesResult,
48
+ NamespaceSummary,
47
49
  RecallManualHit,
48
50
  RecallManualOptions,
49
51
  RecallManualResult,
@@ -114,6 +116,8 @@ __all__ = [
114
116
  "AnalyzedFact",
115
117
  "HealthResult",
116
118
  "RestoreResult",
119
+ "NamespaceSummary",
120
+ "NamespacesResult",
117
121
  "ScoringWeights",
118
122
  "RememberManualOptions",
119
123
  "RememberManualResult",
@@ -122,4 +126,4 @@ __all__ = [
122
126
  "RecallManualResult",
123
127
  ]
124
128
 
125
- __version__ = "0.1.10.dev0"
129
+ __version__ = "0.1.11.dev0"
@@ -35,7 +35,7 @@ import time
35
35
  import uuid
36
36
  from datetime import datetime, timezone
37
37
  from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
38
- from urllib.parse import ParseResult, urlparse
38
+ from urllib.parse import ParseResult, urlencode, urlparse
39
39
 
40
40
  import httpx
41
41
  import nacl.signing
@@ -50,6 +50,8 @@ from .types import (
50
50
  EmbedResult,
51
51
  HealthResult,
52
52
  MemWalConfig,
53
+ NamespacesResult,
54
+ NamespaceSummary,
53
55
  RecallManualHit,
54
56
  RecallManualOptions,
55
57
  RecallManualResult,
@@ -289,6 +291,8 @@ class MemWal:
289
291
  self._session_build_task: Optional[asyncio.Task[str]] = None
290
292
  self._relayer_version_metadata: Optional[Dict[str, Any]] = None
291
293
  self._compatibility_lock: Optional[asyncio.Lock] = None
294
+ self._owner_address: Optional[str] = None
295
+ self._owner_task: Optional[asyncio.Task[str]] = None
292
296
  # Preserve a generated key across an ambiguous transport failure. A
293
297
  # subsequent identical call then collapses onto the accepted paid job.
294
298
  self._pending_remember_keys: Dict[str, str] = {}
@@ -962,6 +966,72 @@ class MemWal:
962
966
  failed=data.get("failed", 0),
963
967
  )
964
968
 
969
+ async def list_namespaces(
970
+ self,
971
+ cursor: Optional[str] = None,
972
+ limit: Optional[int] = None,
973
+ ) -> NamespacesResult:
974
+ """List the namespaces this account holds memories in.
975
+
976
+ Recall needs a namespace to search. Without this, an agent on an
977
+ unfamiliar account has to guess names or fall back to ``"default"``.
978
+ Returns metadata only: no blob fetch, no decryption.
979
+
980
+ Namespaces are flat and exact-match. To work with a prefix such as
981
+ ``proj/``, filter the names here and recall each one.
982
+
983
+ Paginate on ``has_more``, NOT page length: the relayer clamps
984
+ ``limit``, so asking for more than the cap returns exactly the cap.
985
+
986
+ Example::
987
+
988
+ cursor = None
989
+ while True:
990
+ page = await memwal.list_namespaces(cursor=cursor)
991
+ for ns in page.namespaces:
992
+ print(ns.name, ns.memory_count)
993
+ cursor = page.next_cursor
994
+ if not page.has_more:
995
+ break
996
+
997
+ Args:
998
+ cursor: A previous page's ``next_cursor``, to continue a walk or
999
+ poll for namespaces changed since then. Opaque; not a
1000
+ timestamp or a namespace name.
1001
+ limit: Page size. The relayer defaults to 100 and clamps to 500.
1002
+
1003
+ Returns:
1004
+ :class:`NamespacesResult`.
1005
+ """
1006
+ owner = await self._resolve_owner()
1007
+
1008
+ params: Dict[str, str] = {}
1009
+ if cursor is not None:
1010
+ params["updated_after"] = cursor
1011
+ if limit is not None:
1012
+ params["limit"] = str(limit)
1013
+ query = urlencode(params)
1014
+
1015
+ # The query string is part of the signed path: the relayer verifies
1016
+ # against `path_and_query`, not `path`.
1017
+ path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "")
1018
+ data = await self._signed_request("GET", path, {}, include_seal_session=False)
1019
+ return NamespacesResult(
1020
+ namespaces=[
1021
+ NamespaceSummary(
1022
+ id=ns["id"],
1023
+ name=ns["name"],
1024
+ memory_count=ns["memory_count"],
1025
+ storage_used=ns["storage_used"],
1026
+ updated_at=ns["updated_at"],
1027
+ )
1028
+ for ns in data["namespaces"]
1029
+ ],
1030
+ next_cursor=data.get("next_cursor"),
1031
+ has_more=data["has_more"],
1032
+ snapshot_version=data["snapshot_version"],
1033
+ )
1034
+
965
1035
  async def health(self) -> HealthResult:
966
1036
  """Check server health. No authentication required.
967
1037
 
@@ -1255,6 +1325,43 @@ class MemWal:
1255
1325
  finally:
1256
1326
  self._session_build_task = None
1257
1327
 
1328
+ async def _resolve_owner_inner(self) -> str:
1329
+ # POST /api/stats authenticates with the same delegate scheme and
1330
+ # returns the owner the relayer resolved from our key. Same approach
1331
+ # as the TypeScript SDK's resolveOwner().
1332
+ data = await self._signed_request(
1333
+ "POST",
1334
+ "/api/stats",
1335
+ {"namespace": self._namespace},
1336
+ include_seal_session=False,
1337
+ )
1338
+ owner = data.get("owner")
1339
+ if not owner:
1340
+ raise MemWalError(
1341
+ "Walrus Memory could not resolve this account's owner address "
1342
+ "(POST /api/stats returned no owner)."
1343
+ )
1344
+ self._owner_address = owner
1345
+ return owner
1346
+
1347
+ async def _resolve_owner(self) -> str:
1348
+ """Owner address for this account, memoised for the client's life.
1349
+
1350
+ The owner-scoped read routes take the address in the path, but the
1351
+ client is configured with only a delegate key and account id.
1352
+ """
1353
+ if self._owner_address is not None:
1354
+ return self._owner_address
1355
+
1356
+ if self._owner_task is not None:
1357
+ return await self._owner_task
1358
+
1359
+ self._owner_task = asyncio.create_task(self._resolve_owner_inner())
1360
+ try:
1361
+ return await self._owner_task
1362
+ finally:
1363
+ self._owner_task = None
1364
+
1258
1365
  async def _signed_request(
1259
1366
  self,
1260
1367
  method: str,
@@ -1668,6 +1775,14 @@ class MemWalSync:
1668
1775
  (matches server + TypeScript SDK)."""
1669
1776
  return self._run(self._inner.restore(namespace, limit))
1670
1777
 
1778
+ def list_namespaces(
1779
+ self,
1780
+ cursor: Optional[str] = None,
1781
+ limit: Optional[int] = None,
1782
+ ) -> NamespacesResult:
1783
+ """Synchronous version of :meth:`MemWal.list_namespaces`."""
1784
+ return self._run(self._inner.list_namespaces(cursor, limit))
1785
+
1671
1786
  def health(self) -> HealthResult:
1672
1787
  """Synchronous version of :meth:`MemWal.health`."""
1673
1788
  return self._run(self._inner.health())
@@ -3,11 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import base64
7
+ import json
6
8
  import math
7
9
  import re
8
10
  import unicodedata
9
11
  from dataclasses import dataclass
10
- from datetime import datetime
12
+ from datetime import datetime, timedelta, timezone
11
13
  from typing import Any, Dict, List, Optional, Sequence, Union
12
14
 
13
15
  from .types import (
@@ -18,6 +20,8 @@ from .types import (
18
20
  AskResult,
19
21
  EmbedResult,
20
22
  HealthResult,
23
+ NamespacesResult,
24
+ NamespaceSummary,
21
25
  RecallMemory,
22
26
  RecallParams,
23
27
  RecallResult,
@@ -34,6 +38,8 @@ from .types import (
34
38
  RestoreResult,
35
39
  )
36
40
 
41
+ _MOCK_NAMESPACE_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc)
42
+
37
43
 
38
44
  @dataclass
39
45
  class MemWalMockSeed:
@@ -66,6 +72,24 @@ def _distance(query_tokens: set[str], text: str) -> float:
66
72
  return 1.0 - matches / len(query_tokens)
67
73
 
68
74
 
75
+ def _mock_timestamp(sequence: int) -> str:
76
+ # Same shape as the TypeScript mock's Date.toISOString(), so fixed-width
77
+ # timestamps compare correctly as strings.
78
+ moment = _MOCK_NAMESPACE_EPOCH + timedelta(seconds=sequence)
79
+ return moment.strftime("%Y-%m-%dT%H:%M:%S.000Z")
80
+
81
+
82
+ def _encode_namespaces_cursor(payload: Dict[str, Any]) -> str:
83
+ # The relayer's cursor: URL-safe unpadded base64 of a JSON object.
84
+ raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
85
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
86
+
87
+
88
+ def _decode_namespaces_cursor(cursor: str) -> Dict[str, Any]:
89
+ padded = cursor + "=" * (-len(cursor) % 4)
90
+ return json.loads(base64.urlsafe_b64decode(padded))
91
+
92
+
69
93
  def _validate_text(text: str, field: str = "text") -> None:
70
94
  if not isinstance(text, str) or not text.strip():
71
95
  raise ValueError(f"{field} cannot be empty")
@@ -344,6 +368,67 @@ class MemWalMock:
344
368
  failed=0,
345
369
  )
346
370
 
371
+ async def list_namespaces(
372
+ self,
373
+ cursor: Optional[str] = None,
374
+ limit: Optional[int] = None,
375
+ ) -> NamespacesResult:
376
+ grouped: Dict[str, List[_Memory]] = {}
377
+ for memory in self._memories:
378
+ grouped.setdefault(memory.namespace, []).append(memory)
379
+ summaries = sorted(
380
+ (
381
+ NamespaceSummary(
382
+ id=f"mock-ns-{name}",
383
+ name=name,
384
+ memory_count=len(memories),
385
+ storage_used=sum(len(m.text.encode("utf-8")) for m in memories),
386
+ updated_at=_mock_timestamp(max(m.sequence for m in memories)),
387
+ )
388
+ for name, memories in grouped.items()
389
+ ),
390
+ key=lambda ns: (ns.updated_at, ns.name),
391
+ )
392
+
393
+ # Keyset walk pinned to a snapshot, like the relayer: writes made
394
+ # mid-walk surface on the next poll, not in the current walk.
395
+ after = _decode_namespaces_cursor(cursor) if cursor is not None else None
396
+ snapshot_at = (after or {}).get("snapshot_at") or _mock_timestamp(self._sequence)
397
+ remaining = [
398
+ ns
399
+ for ns in summaries
400
+ if ns.updated_at <= snapshot_at
401
+ and (
402
+ after is None
403
+ or (ns.updated_at, ns.name) > (after["updated_at"], after["namespace"])
404
+ )
405
+ ]
406
+ page = remaining if limit is None else remaining[:limit]
407
+ has_more = len(remaining) > len(page)
408
+
409
+ watermark = (
410
+ {"updated_at": page[-1].updated_at, "namespace": page[-1].name} if page else after
411
+ )
412
+ next_cursor = None
413
+ if watermark is not None:
414
+ next_cursor = _encode_namespaces_cursor(
415
+ {
416
+ "updated_at": watermark["updated_at"],
417
+ "namespace": watermark["namespace"],
418
+ # A finished walk drops the snapshot so the next poll
419
+ # takes a fresh one.
420
+ "snapshot_at": snapshot_at if has_more else None,
421
+ }
422
+ )
423
+
424
+ return NamespacesResult(
425
+ namespaces=page,
426
+ next_cursor=next_cursor,
427
+ has_more=has_more,
428
+ # Matches the live relayer's current wire-format version.
429
+ snapshot_version=2,
430
+ )
431
+
347
432
  async def health(self) -> HealthResult:
348
433
  return HealthResult(
349
434
  status="ok",
@@ -582,6 +667,13 @@ class MemWalMockSync:
582
667
  def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
583
668
  return self._run(self._inner.restore(namespace, limit))
584
669
 
670
+ def list_namespaces(
671
+ self,
672
+ cursor: Optional[str] = None,
673
+ limit: Optional[int] = None,
674
+ ) -> NamespacesResult:
675
+ return self._run(self._inner.list_namespaces(cursor, limit))
676
+
585
677
  def health(self) -> HealthResult:
586
678
  return self._run(self._inner.health())
587
679
 
@@ -228,6 +228,34 @@ class RestoreResult:
228
228
  failed: int = 0
229
229
 
230
230
 
231
+ @dataclass
232
+ class NamespaceSummary:
233
+ """One namespace in a :meth:`MemWal.list_namespaces` page."""
234
+
235
+ id: str
236
+ name: str
237
+ memory_count: int
238
+ storage_used: int
239
+ #: ``MAX(updated_at)`` across the namespace's memories (RFC 3339), the
240
+ #: same value the relayer builds the keyset cursor from.
241
+ updated_at: str
242
+
243
+
244
+ @dataclass
245
+ class NamespacesResult:
246
+ """Result from list_namespaces()."""
247
+
248
+ namespaces: List[NamespaceSummary]
249
+ #: Pass back as ``cursor`` on the next call. Set on every page, including
250
+ #: the last, so a caller that finished a walk can poll from it later.
251
+ next_cursor: Optional[str]
252
+ #: Whether to keep paginating. Do NOT infer this from page length: the
253
+ #: relayer clamps ``limit``, so asking for more than the cap returns
254
+ #: exactly the cap.
255
+ has_more: bool
256
+ snapshot_version: int
257
+
258
+
231
259
  @dataclass
232
260
  class AskMemory:
233
261
  """A memory used to answer a question."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.10.dev0"
7
+ version = "0.1.11.dev0"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -257,6 +257,30 @@ class TestRemember:
257
257
  assert result.namespace == _E2E_NAMESPACE_ALT
258
258
 
259
259
 
260
+ @requires_key
261
+ class TestListNamespaces:
262
+ """list_namespaces() against live server. Read-only; writes nothing."""
263
+
264
+ def test_walks_every_page_on_has_more(self) -> None:
265
+ # limit=1 forces a cursor into the signed query string on every page
266
+ # after the first, which is what the relayer verifies the signature over.
267
+ mw = _sync_client()
268
+ names: list[str] = []
269
+ cursor = None
270
+ for _ in range(1000):
271
+ page = mw.list_namespaces(cursor=cursor, limit=1)
272
+ assert len(page.namespaces) <= 1
273
+ assert page.snapshot_version >= 1
274
+ names.extend(ns.name for ns in page.namespaces)
275
+ cursor = page.next_cursor
276
+ if not page.has_more:
277
+ break
278
+ else:
279
+ pytest.fail("namespace walk did not finish in 1000 pages")
280
+ assert len(names) == len(set(names)), "a single walk must not repeat a namespace"
281
+ print(f"\n namespaces={len(names)}")
282
+
283
+
260
284
  @requires_key
261
285
  class TestRecall:
262
286
  """recall() against live server."""
@@ -0,0 +1,220 @@
1
+ """
2
+ Tests for ``MemWal.list_namespaces()`` — owner-scoped namespace discovery.
3
+
4
+ Mirrors ``packages/sdk/test/list-namespaces.test.mjs`` so both SDKs pin the
5
+ same wire contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from urllib.parse import parse_qs
12
+
13
+ import httpx
14
+ import nacl.signing
15
+ import pytest
16
+ import respx
17
+
18
+ from memwal import MemWal, MemWalError, MemWalSync, NamespacesResult, NamespaceSummary
19
+ from memwal.utils import build_signature_message, bytes_to_hex, sha256_hex
20
+
21
+ _SERVER = "https://relayer.example"
22
+ _OWNER = "0xowner0000000000000000000000000000000000000000000000000000000001"
23
+ _NAMESPACES_URL = f"{_SERVER}/v1/owners/{_OWNER}/namespaces"
24
+ _KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x01" * 32)))
25
+
26
+ _STATS = {"memory_count": 0, "storage_bytes": 0, "namespace": "default", "owner": _OWNER}
27
+
28
+ _ONE_PAGE = {
29
+ "namespaces": [
30
+ {
31
+ "id": "ns-1",
32
+ "name": "work",
33
+ "memory_count": 12,
34
+ "storage_used": 2048,
35
+ "updated_at": "2026-08-20T10:00:00Z",
36
+ }
37
+ ],
38
+ "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOC0yMFQxMDowMDowMFoifQ",
39
+ "has_more": False,
40
+ "snapshot_version": 2,
41
+ }
42
+
43
+
44
+ def _client() -> MemWal:
45
+ return MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
46
+
47
+
48
+ def _stub_relayer(
49
+ namespaces_body: dict = _ONE_PAGE,
50
+ stats_body: dict | None = None,
51
+ ) -> tuple[respx.Route, respx.Route]:
52
+ """Stub the three calls a list_namespaces() round-trip makes: the
53
+ compatibility preflight, the owner resolution, and the read itself.
54
+
55
+ Anything else (``/config``, Sui GraphQL for a SEAL session) is unmocked,
56
+ so respx fails the test if the client reaches for it.
57
+ """
58
+ respx.get(f"{_SERVER}/version").mock(
59
+ return_value=httpx.Response(
60
+ 200,
61
+ json={
62
+ "apiVersion": "1.0.0",
63
+ "relayerVersion": "1.0.0",
64
+ "minSupportedSdk": {"typescript": "0.0.4", "python": "0.1.0", "mcp": "0.0.1"},
65
+ },
66
+ )
67
+ )
68
+ stats = respx.post(f"{_SERVER}/api/stats").mock(
69
+ return_value=httpx.Response(
70
+ 200,
71
+ json=stats_body if stats_body is not None else _STATS,
72
+ )
73
+ )
74
+ namespaces = respx.get(_NAMESPACES_URL).mock(
75
+ return_value=httpx.Response(200, json=namespaces_body)
76
+ )
77
+ return stats, namespaces
78
+
79
+
80
+ class TestListNamespaces:
81
+ @respx.mock
82
+ async def test_reads_the_owner_scoped_namespaces_path(self) -> None:
83
+ _, namespaces = _stub_relayer()
84
+
85
+ await _client().list_namespaces()
86
+
87
+ assert namespaces.call_count == 1
88
+ request = namespaces.calls[0].request
89
+ assert request.method == "GET"
90
+ assert request.url.path == f"/v1/owners/{_OWNER}/namespaces"
91
+
92
+ @respx.mock
93
+ async def test_resolves_the_owner_once_and_reuses_it(self) -> None:
94
+ stats, namespaces = _stub_relayer()
95
+ memwal = _client()
96
+
97
+ await memwal.list_namespaces()
98
+ await memwal.list_namespaces()
99
+
100
+ assert stats.call_count == 1, "owner resolution must be memoised across calls"
101
+ assert namespaces.call_count == 2
102
+
103
+ @respx.mock
104
+ async def test_concurrent_first_calls_share_one_owner_lookup(self) -> None:
105
+ stats, namespaces = _stub_relayer()
106
+ owner_response = stats.return_value
107
+
108
+ async def slow_stats(request: httpx.Request) -> httpx.Response:
109
+ # respx otherwise answers without yielding, so the two calls
110
+ # would run back to back instead of overlapping.
111
+ await asyncio.sleep(0.01)
112
+ return owner_response
113
+
114
+ stats.side_effect = slow_stats
115
+ memwal = _client()
116
+
117
+ await asyncio.gather(memwal.list_namespaces(), memwal.list_namespaces())
118
+
119
+ assert stats.call_count == 1
120
+ assert namespaces.call_count == 2
121
+
122
+ @respx.mock
123
+ async def test_forwards_cursor_as_updated_after_and_passes_limit(self) -> None:
124
+ _, namespaces = _stub_relayer()
125
+
126
+ await _client().list_namespaces(cursor="opaque-cursor_1", limit=25)
127
+
128
+ params = parse_qs(namespaces.calls[0].request.url.query.decode())
129
+ assert params == {"updated_after": ["opaque-cursor_1"], "limit": ["25"]}
130
+
131
+ @respx.mock
132
+ async def test_omits_query_params_that_were_not_supplied(self) -> None:
133
+ _, namespaces = _stub_relayer()
134
+
135
+ await _client().list_namespaces()
136
+
137
+ assert namespaces.calls[0].request.url.query == b""
138
+
139
+ @respx.mock
140
+ async def test_signature_covers_the_query_string(self) -> None:
141
+ # The relayer verifies against `path_and_query`, not `path`.
142
+ _, namespaces = _stub_relayer()
143
+
144
+ await _client().list_namespaces(cursor="abc", limit=5)
145
+
146
+ request = namespaces.calls[0].request
147
+ assert request.content == b""
148
+ headers = request.headers
149
+ message = build_signature_message(
150
+ timestamp=headers["x-timestamp"],
151
+ method="GET",
152
+ path=f"/v1/owners/{_OWNER}/namespaces?updated_after=abc&limit=5",
153
+ body_sha256=sha256_hex(""),
154
+ nonce=headers["x-nonce"],
155
+ account_id=headers["x-account-id"],
156
+ )
157
+ verify_key = nacl.signing.VerifyKey(bytes.fromhex(headers["x-public-key"]))
158
+ verify_key.verify(message.encode("utf-8"), bytes.fromhex(headers["x-signature"]))
159
+
160
+ @respx.mock
161
+ async def test_returns_the_relayer_wire_shape(self) -> None:
162
+ _stub_relayer()
163
+
164
+ result = await _client().list_namespaces()
165
+
166
+ assert result == NamespacesResult(
167
+ namespaces=[
168
+ NamespaceSummary(
169
+ id="ns-1",
170
+ name="work",
171
+ memory_count=12,
172
+ storage_used=2048,
173
+ updated_at="2026-08-20T10:00:00Z",
174
+ )
175
+ ],
176
+ next_cursor=_ONE_PAGE["next_cursor"],
177
+ has_more=False,
178
+ snapshot_version=2,
179
+ )
180
+
181
+ @respx.mock
182
+ async def test_sends_no_seal_session_on_a_metadata_only_read(self) -> None:
183
+ stats, namespaces = _stub_relayer()
184
+
185
+ await _client().list_namespaces()
186
+
187
+ for route in (stats, namespaces):
188
+ assert "x-seal-session" not in route.calls[0].request.headers
189
+
190
+ @respx.mock
191
+ async def test_raises_when_stats_returns_no_owner(self) -> None:
192
+ _stub_relayer(stats_body={"memory_count": 0, "storage_bytes": 0, "namespace": "default"})
193
+
194
+ with pytest.raises(MemWalError, match="owner"):
195
+ await _client().list_namespaces()
196
+
197
+ @respx.mock
198
+ async def test_a_failed_owner_lookup_is_retried_on_the_next_call(self) -> None:
199
+ stats, namespaces = _stub_relayer()
200
+ stats.side_effect = [
201
+ httpx.Response(503, text="busy"),
202
+ httpx.Response(200, json=_STATS),
203
+ ]
204
+ memwal = _client()
205
+
206
+ with pytest.raises(MemWalError):
207
+ await memwal.list_namespaces()
208
+ await memwal.list_namespaces()
209
+
210
+ assert stats.call_count == 2
211
+ assert namespaces.call_count == 1
212
+
213
+ @respx.mock
214
+ def test_sync_wrapper(self) -> None:
215
+ _stub_relayer()
216
+ client = MemWalSync.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
217
+
218
+ result = client.list_namespaces(limit=10)
219
+
220
+ assert [ns.name for ns in result.namespaces] == ["work"]
@@ -1,6 +1,9 @@
1
1
  """Offline mock client regression tests."""
2
2
 
3
+ import base64
3
4
  import inspect
5
+ import json
6
+ import re
4
7
 
5
8
  import pytest
6
9
 
@@ -162,3 +165,102 @@ async def test_sync_mock_works_inside_an_existing_event_loop():
162
165
 
163
166
  assert stored.namespace == "notebook"
164
167
  assert recalled.results[0].text == "called from a running loop"
168
+
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_mock_list_namespaces_aggregates_memories_by_namespace():
172
+ mock = MemWalMock.create(
173
+ initial_memories=[
174
+ MemWalMockSeed(text="one", namespace="work"),
175
+ MemWalMockSeed(text="two", namespace="work"),
176
+ MemWalMockSeed(text="旅行", namespace="home"),
177
+ ]
178
+ )
179
+
180
+ page = await mock.list_namespaces()
181
+ by_name = {ns.name: ns for ns in page.namespaces}
182
+
183
+ assert sorted(by_name) == ["home", "work"]
184
+ assert by_name["work"].memory_count == 2
185
+ assert by_name["work"].storage_used == 6
186
+ assert by_name["home"].storage_used == len("旅行".encode("utf-8"))
187
+ assert page.has_more is False
188
+ # Matches the live relayer's current wire-format version.
189
+ assert page.snapshot_version == 2
190
+
191
+
192
+ @pytest.mark.asyncio
193
+ async def test_mock_list_namespaces_reports_has_more_when_limit_truncates():
194
+ mock = MemWalMock.create(
195
+ initial_memories=[
196
+ MemWalMockSeed(text="a", namespace="alpha"),
197
+ MemWalMockSeed(text="b", namespace="bravo"),
198
+ MemWalMockSeed(text="c", namespace="charlie"),
199
+ ]
200
+ )
201
+
202
+ page = await mock.list_namespaces(limit=2)
203
+
204
+ assert len(page.namespaces) == 2
205
+ assert page.has_more is True, "has_more is the pagination signal, not page length"
206
+ assert page.next_cursor
207
+
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_mock_namespace_cursor_uses_the_relayer_wire_format_and_resets_after_a_walk():
211
+ mock = MemWalMock.create(
212
+ initial_memories=[
213
+ MemWalMockSeed(text="a", namespace="旅行"),
214
+ MemWalMockSeed(text="b", namespace="work"),
215
+ ]
216
+ )
217
+
218
+ first = await mock.list_namespaces(limit=1)
219
+ assert re.fullmatch(r"[A-Za-z0-9_-]+", first.next_cursor)
220
+ padded = first.next_cursor + "=" * (-len(first.next_cursor) % 4)
221
+ cursor = json.loads(base64.urlsafe_b64decode(padded))
222
+ assert cursor["namespace"] == "旅行"
223
+ assert cursor["updated_at"] == first.namespaces[0].updated_at
224
+ assert cursor["snapshot_at"]
225
+
226
+ last = await mock.list_namespaces(cursor=first.next_cursor)
227
+ assert [ns.name for ns in last.namespaces] == ["work"]
228
+ assert last.has_more is False
229
+ padded = last.next_cursor + "=" * (-len(last.next_cursor) % 4)
230
+ assert json.loads(base64.urlsafe_b64decode(padded))["snapshot_at"] is None
231
+
232
+ empty = await mock.list_namespaces(cursor=last.next_cursor)
233
+ assert empty.namespaces == []
234
+ assert empty.next_cursor == last.next_cursor
235
+
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_mock_namespace_walk_defers_new_writes_until_the_next_poll():
239
+ mock = MemWalMock.create(
240
+ initial_memories=[
241
+ MemWalMockSeed(text="a", namespace="alpha"),
242
+ MemWalMockSeed(text="b", namespace="bravo"),
243
+ ]
244
+ )
245
+
246
+ first = await mock.list_namespaces(limit=1)
247
+ await mock.remember("new", "bravo")
248
+ last = await mock.list_namespaces(cursor=first.next_cursor)
249
+ assert last.namespaces == []
250
+ assert last.has_more is False
251
+
252
+ poll = await mock.list_namespaces(cursor=last.next_cursor)
253
+ assert [ns.name for ns in poll.namespaces] == ["bravo"]
254
+ assert poll.namespaces[0].memory_count == 2
255
+
256
+
257
+ def test_sync_mock_list_namespaces_matches_production():
258
+ mock = MemWalMockSync.create(namespace="sync")
259
+ mock.remember("sync memory")
260
+
261
+ page = mock.list_namespaces(limit=10)
262
+
263
+ assert [ns.name for ns in page.namespaces] == ["sync"]
264
+ assert inspect.signature(MemWalMockSync.list_namespaces) == inspect.signature(
265
+ MemWalSync.list_namespaces
266
+ )
File without changes
File without changes