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.
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/CHANGELOG.md +6 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/PKG-INFO +3 -2
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/README.md +2 -1
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/__init__.py +5 -1
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/client.py +116 -1
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/mock.py +93 -1
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/types.py +28 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/pyproject.toml +1 -1
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_integration.py +24 -0
- memwal-0.1.11.dev0/tests/test_list_namespaces.py +220 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_mock.py +102 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/.gitignore +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/.env.example +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/.gitignore +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/interactive_demo.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/examples/verify_credentials.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/compatibility.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/middleware.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/memwal/utils.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/run_tests.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/__init__.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_client.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_env_presets.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_middleware.py +0 -0
- {memwal-0.1.10.dev0 → memwal-0.1.11.dev0}/tests/test_normalize_server_url.py +0 -0
- {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.
|
|
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.
|
|
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."""
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|