memwal 0.2.1.dev0__py3-none-any.whl → 0.2.2.dev1__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.
- memwal/__init__.py +1 -1
- memwal/client.py +220 -48
- memwal/mock.py +10 -3
- memwal/types.py +7 -4
- memwal/utils.py +23 -10
- {memwal-0.2.1.dev0.dist-info → memwal-0.2.2.dev1.dist-info}/METADATA +11 -1
- memwal-0.2.2.dev1.dist-info/RECORD +10 -0
- memwal-0.2.1.dev0.dist-info/RECORD +0 -10
- {memwal-0.2.1.dev0.dist-info → memwal-0.2.2.dev1.dist-info}/WHEEL +0 -0
memwal/__init__.py
CHANGED
memwal/client.py
CHANGED
|
@@ -142,6 +142,24 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
|
|
|
142
142
|
raise ValueError(message)
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _require(data: object, field: str, endpoint: str) -> Any:
|
|
146
|
+
"""``data[field]`` from a parsed 2xx body, or a ``MemWalError`` naming both.
|
|
147
|
+
|
|
148
|
+
A bare ``KeyError`` is not a ``MemWalError``, carries only the field name,
|
|
149
|
+
and is raised far from the response that caused it (#1250). Optional
|
|
150
|
+
fields keep using ``.get()`` with a documented default.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
raise MemWalError(
|
|
155
|
+
f"{endpoint} response was {type(data).__name__}, expected an object "
|
|
156
|
+
f"with {field!r}"
|
|
157
|
+
)
|
|
158
|
+
if field not in data:
|
|
159
|
+
raise MemWalError(f"{endpoint} response missing {field!r}")
|
|
160
|
+
return data[field]
|
|
161
|
+
|
|
162
|
+
|
|
145
163
|
IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
|
|
146
164
|
|
|
147
165
|
|
|
@@ -437,6 +455,12 @@ def _check_vector(vector: Any) -> None:
|
|
|
437
455
|
):
|
|
438
456
|
raise ValueError("vector must be a non-empty list of finite numbers")
|
|
439
457
|
|
|
458
|
+
def _destroyed_client_error() -> "MemWalError":
|
|
459
|
+
return MemWalError(
|
|
460
|
+
"Walrus Memory client was destroyed; its keys are zeroed. Create a new one."
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
440
464
|
class MemWal:
|
|
441
465
|
"""Async-native Walrus Memory client.
|
|
442
466
|
|
|
@@ -445,8 +469,13 @@ class MemWal:
|
|
|
445
469
|
"""
|
|
446
470
|
|
|
447
471
|
def __init__(self, config: MemWalConfig) -> None:
|
|
448
|
-
self.
|
|
449
|
-
self.
|
|
472
|
+
self._destroyed: bool = False
|
|
473
|
+
self._private_key_hex: str = normalize_private_key(config.key)
|
|
474
|
+
signing_key = build_signing_key(self._private_key_hex)
|
|
475
|
+
self._signing_key: Optional[nacl.signing.SigningKey] = signing_key
|
|
476
|
+
# Mutable copy of the 32-byte seed so destroy() can zero-fill it in
|
|
477
|
+
# place without mutating any caller-supplied bytearray (#975).
|
|
478
|
+
self._private_key_bytes: bytearray = bytearray(bytes(signing_key))
|
|
450
479
|
self._account_id = config.account_id
|
|
451
480
|
self._server_url = normalize_server_url(config.server_url)
|
|
452
481
|
self._namespace = config.namespace
|
|
@@ -473,7 +502,7 @@ class MemWal:
|
|
|
473
502
|
@classmethod
|
|
474
503
|
def create(
|
|
475
504
|
cls,
|
|
476
|
-
key: str,
|
|
505
|
+
key: Union[str, bytes, bytearray],
|
|
477
506
|
account_id: str,
|
|
478
507
|
server_url: Optional[str] = None,
|
|
479
508
|
namespace: str = "default",
|
|
@@ -482,7 +511,10 @@ class MemWal:
|
|
|
482
511
|
"""Create a new Walrus Memory client instance.
|
|
483
512
|
|
|
484
513
|
Args:
|
|
485
|
-
key: Ed25519 private key hex string
|
|
514
|
+
key: Ed25519 private key (hex string, ``suiprivkey1...``, or 32-byte
|
|
515
|
+
``bytes`` / ``bytearray``). When passed as ``bytearray``, the
|
|
516
|
+
bytes are copied into an internal buffer so :meth:`destroy`
|
|
517
|
+
zeroes only the client's copy and leaves the caller's buffer intact.
|
|
486
518
|
account_id: Walrus Memory account object ID on Sui.
|
|
487
519
|
server_url: Server URL (default: ``https://relayer.memory.walrus.xyz``).
|
|
488
520
|
namespace: Default namespace for memory isolation (default: ``"default"``).
|
|
@@ -510,8 +542,65 @@ class MemWal:
|
|
|
510
542
|
self._client = httpx.AsyncClient(timeout=60.0)
|
|
511
543
|
return self._client
|
|
512
544
|
|
|
513
|
-
|
|
514
|
-
"""
|
|
545
|
+
def destroy(self) -> None:
|
|
546
|
+
"""Wipe internal key buffers, drop signing credentials, and invalidate the client.
|
|
547
|
+
|
|
548
|
+
Provides parity with the TypeScript SDK's ``destroy()`` method (#975):
|
|
549
|
+
|
|
550
|
+
- Zeroes ``_private_key_bytes`` (the client's internal ``bytearray`` seed
|
|
551
|
+
copy) in place.
|
|
552
|
+
- Clears ``_private_key_hex``, drops the ``_signing_key`` reference, and
|
|
553
|
+
clears cached SEAL session tokens, server config, owner address, and
|
|
554
|
+
pending idempotency keys.
|
|
555
|
+
- Marks the client as destroyed so any subsequent or in-flight operation
|
|
556
|
+
raises :class:`MemWalError`.
|
|
557
|
+
|
|
558
|
+
Note on CPython memory semantics:
|
|
559
|
+
Python ``str`` and ``bytes`` objects (and PyNaCl's internal C-level
|
|
560
|
+
``SigningKey`` buffers) are immutable in CPython and cannot be zeroed
|
|
561
|
+
in place from pure Python. ``destroy()`` zeroes the client's mutable
|
|
562
|
+
``bytearray`` seed copy in place and drops all references to immutable
|
|
563
|
+
key objects so they become eligible for garbage collection once the
|
|
564
|
+
caller also releases its references. Callers that want to minimize
|
|
565
|
+
long-lived plaintext ``str`` keys in memory should pass ``key`` as a
|
|
566
|
+
32-byte ``bytearray`` and zero their own buffer after ``create()``.
|
|
567
|
+
"""
|
|
568
|
+
self._destroyed = True
|
|
569
|
+
if self._private_key_bytes:
|
|
570
|
+
self._private_key_bytes[:] = b"\x00" * len(self._private_key_bytes)
|
|
571
|
+
self._private_key_hex = ""
|
|
572
|
+
self._signing_key = None
|
|
573
|
+
self._session_cache = None
|
|
574
|
+
self._server_config = None
|
|
575
|
+
self._relayer_version_metadata = None
|
|
576
|
+
self._owner_address = None
|
|
577
|
+
self._pending_remember_keys.clear()
|
|
578
|
+
for attr in ("_session_build_task", "_owner_task"):
|
|
579
|
+
task = getattr(self, attr, None)
|
|
580
|
+
setattr(self, attr, None)
|
|
581
|
+
if task is not None and not task.done():
|
|
582
|
+
task.cancel()
|
|
583
|
+
|
|
584
|
+
async def close(self, clear_keys: bool = False) -> None:
|
|
585
|
+
"""Close the underlying HTTP client and cancel any in-flight initialization tasks.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
clear_keys: If ``True``, also call :meth:`destroy` to wipe internal
|
|
589
|
+
key buffers, drop signing credentials, and invalidate the client.
|
|
590
|
+
"""
|
|
591
|
+
for attr in ("_session_build_task", "_owner_task"):
|
|
592
|
+
task = getattr(self, attr, None)
|
|
593
|
+
setattr(self, attr, None)
|
|
594
|
+
if task is not None and not task.done():
|
|
595
|
+
task.cancel()
|
|
596
|
+
try:
|
|
597
|
+
await task
|
|
598
|
+
except (asyncio.CancelledError, Exception):
|
|
599
|
+
# Ignore cancellation or transport errors from in-flight
|
|
600
|
+
# session tasks during teardown.
|
|
601
|
+
pass
|
|
602
|
+
if clear_keys:
|
|
603
|
+
self.destroy()
|
|
515
604
|
if self._client is not None and not self._client.is_closed:
|
|
516
605
|
await self._client.aclose()
|
|
517
606
|
self._client = None
|
|
@@ -1118,9 +1207,9 @@ class MemWal:
|
|
|
1118
1207
|
data = await self._signed_request("POST", "/api/recall", body)
|
|
1119
1208
|
memories = [
|
|
1120
1209
|
RecallMemory(
|
|
1121
|
-
blob_id=m
|
|
1122
|
-
text=m
|
|
1123
|
-
distance=m
|
|
1210
|
+
blob_id=_require(m, "blob_id", "POST /api/recall"),
|
|
1211
|
+
text=_require(m, "text", "POST /api/recall"),
|
|
1212
|
+
distance=_require(m, "distance", "POST /api/recall"),
|
|
1124
1213
|
score=m.get("score"),
|
|
1125
1214
|
created_at=m.get("created_at"),
|
|
1126
1215
|
)
|
|
@@ -1288,12 +1377,16 @@ class MemWal:
|
|
|
1288
1377
|
"namespace": self._resolve_namespace(namespace),
|
|
1289
1378
|
})
|
|
1290
1379
|
memories = [
|
|
1291
|
-
AskMemory(
|
|
1380
|
+
AskMemory(
|
|
1381
|
+
blob_id=_require(m, "blob_id", "POST /api/ask"),
|
|
1382
|
+
text=_require(m, "text", "POST /api/ask"),
|
|
1383
|
+
distance=_require(m, "distance", "POST /api/ask"),
|
|
1384
|
+
)
|
|
1292
1385
|
for m in data.get("memories", [])
|
|
1293
1386
|
]
|
|
1294
1387
|
return AskResult(
|
|
1295
|
-
answer=data
|
|
1296
|
-
memories_used=data
|
|
1388
|
+
answer=_require(data, "answer", "POST /api/ask"),
|
|
1389
|
+
memories_used=_require(data, "memories_used", "POST /api/ask"),
|
|
1297
1390
|
memories=memories,
|
|
1298
1391
|
)
|
|
1299
1392
|
|
|
@@ -1353,12 +1446,13 @@ class MemWal:
|
|
|
1353
1446
|
"namespace": namespace,
|
|
1354
1447
|
"limit": limit,
|
|
1355
1448
|
})
|
|
1449
|
+
endpoint = "POST /api/restore"
|
|
1356
1450
|
return RestoreResult(
|
|
1357
|
-
restored=data
|
|
1358
|
-
skipped=data
|
|
1359
|
-
total=data
|
|
1360
|
-
namespace=data
|
|
1361
|
-
owner=data
|
|
1451
|
+
restored=_require(data, "restored", endpoint),
|
|
1452
|
+
skipped=_require(data, "skipped", endpoint),
|
|
1453
|
+
total=_require(data, "total", endpoint),
|
|
1454
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1455
|
+
owner=_require(data, "owner", endpoint),
|
|
1362
1456
|
# Relayers older than WALM-319 omit `truncated` entirely --
|
|
1363
1457
|
# treat "not present" as "not known to be truncated" rather
|
|
1364
1458
|
# than require every relayer version to send it.
|
|
@@ -1421,20 +1515,21 @@ class MemWal:
|
|
|
1421
1515
|
# against `path_and_query`, not `path`.
|
|
1422
1516
|
path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "")
|
|
1423
1517
|
data = await self._signed_request("GET", path, {}, include_seal_session=False)
|
|
1518
|
+
endpoint = "GET /v1/owners/{owner}/namespaces"
|
|
1424
1519
|
return NamespacesResult(
|
|
1425
1520
|
namespaces=[
|
|
1426
1521
|
NamespaceSummary(
|
|
1427
|
-
id=ns
|
|
1428
|
-
name=ns
|
|
1429
|
-
memory_count=ns
|
|
1430
|
-
storage_used=ns
|
|
1431
|
-
updated_at=ns
|
|
1522
|
+
id=_require(ns, "id", endpoint),
|
|
1523
|
+
name=_require(ns, "name", endpoint),
|
|
1524
|
+
memory_count=_require(ns, "memory_count", endpoint),
|
|
1525
|
+
storage_used=_require(ns, "storage_used", endpoint),
|
|
1526
|
+
updated_at=_require(ns, "updated_at", endpoint),
|
|
1432
1527
|
)
|
|
1433
|
-
for ns in data
|
|
1528
|
+
for ns in _require(data, "namespaces", endpoint)
|
|
1434
1529
|
],
|
|
1435
1530
|
next_cursor=data.get("next_cursor"),
|
|
1436
|
-
has_more=data
|
|
1437
|
-
snapshot_version=data
|
|
1531
|
+
has_more=_require(data, "has_more", endpoint),
|
|
1532
|
+
snapshot_version=_require(data, "snapshot_version", endpoint),
|
|
1438
1533
|
)
|
|
1439
1534
|
|
|
1440
1535
|
async def health(self) -> HealthResult:
|
|
@@ -1443,13 +1538,17 @@ class MemWal:
|
|
|
1443
1538
|
Returns:
|
|
1444
1539
|
:class:`HealthResult` with status and version.
|
|
1445
1540
|
"""
|
|
1541
|
+
if self._destroyed:
|
|
1542
|
+
raise _destroyed_client_error()
|
|
1446
1543
|
response = await self._http.get(f"{self._server_url}/health")
|
|
1544
|
+
if self._destroyed:
|
|
1545
|
+
raise _destroyed_client_error()
|
|
1447
1546
|
if response.status_code != 200:
|
|
1448
1547
|
raise MemWalError(f"Health check failed: {response.status_code}")
|
|
1449
1548
|
data = response.json()
|
|
1450
1549
|
return HealthResult(
|
|
1451
|
-
status=data
|
|
1452
|
-
version=data
|
|
1550
|
+
status=_require(data, "status", "GET /health"),
|
|
1551
|
+
version=_require(data, "version", "GET /health"),
|
|
1453
1552
|
relayer_version=data.get("relayerVersion"),
|
|
1454
1553
|
api_version=data.get("apiVersion"),
|
|
1455
1554
|
min_supported_sdk=data.get("minSupportedSdk"),
|
|
@@ -1462,7 +1561,8 @@ class MemWal:
|
|
|
1462
1561
|
|
|
1463
1562
|
async def compatibility(self) -> Dict[str, Any]:
|
|
1464
1563
|
"""Fetch and validate the relayer compatibility contract."""
|
|
1465
|
-
|
|
1564
|
+
if self._destroyed:
|
|
1565
|
+
raise _destroyed_client_error()
|
|
1466
1566
|
return await self._ensure_compatible_relayer()
|
|
1467
1567
|
|
|
1468
1568
|
# ============================================================
|
|
@@ -1493,11 +1593,12 @@ class MemWal:
|
|
|
1493
1593
|
},
|
|
1494
1594
|
include_seal_session=False,
|
|
1495
1595
|
)
|
|
1596
|
+
endpoint = "POST /api/remember/manual"
|
|
1496
1597
|
return RememberManualResult(
|
|
1497
|
-
id=data
|
|
1498
|
-
blob_id=data
|
|
1499
|
-
owner=data
|
|
1500
|
-
namespace=data
|
|
1598
|
+
id=_require(data, "id", endpoint),
|
|
1599
|
+
blob_id=_require(data, "blob_id", endpoint),
|
|
1600
|
+
owner=_require(data, "owner", endpoint),
|
|
1601
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1501
1602
|
)
|
|
1502
1603
|
|
|
1503
1604
|
async def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
|
|
@@ -1545,6 +1646,8 @@ class MemWal:
|
|
|
1545
1646
|
Returns:
|
|
1546
1647
|
Public key hex string.
|
|
1547
1648
|
"""
|
|
1649
|
+
if self._destroyed or self._signing_key is None:
|
|
1650
|
+
raise _destroyed_client_error()
|
|
1548
1651
|
return bytes_to_hex(bytes(self._signing_key.verify_key))
|
|
1549
1652
|
|
|
1550
1653
|
# ============================================================
|
|
@@ -1552,6 +1655,8 @@ class MemWal:
|
|
|
1552
1655
|
# ============================================================
|
|
1553
1656
|
|
|
1554
1657
|
async def _ensure_compatible_relayer(self) -> Dict[str, Any]:
|
|
1658
|
+
if self._destroyed:
|
|
1659
|
+
raise _destroyed_client_error()
|
|
1555
1660
|
if self._relayer_version_metadata is not None:
|
|
1556
1661
|
return self._relayer_version_metadata
|
|
1557
1662
|
|
|
@@ -1559,14 +1664,20 @@ class MemWal:
|
|
|
1559
1664
|
self._compatibility_lock = asyncio.Lock()
|
|
1560
1665
|
|
|
1561
1666
|
async with self._compatibility_lock:
|
|
1667
|
+
if self._destroyed:
|
|
1668
|
+
raise _destroyed_client_error()
|
|
1562
1669
|
if self._relayer_version_metadata is not None:
|
|
1563
1670
|
return self._relayer_version_metadata
|
|
1564
1671
|
|
|
1565
1672
|
version_response = await self._http.get(f"{self._server_url}/version")
|
|
1673
|
+
if self._destroyed:
|
|
1674
|
+
raise _destroyed_client_error()
|
|
1566
1675
|
if version_response.status_code == 200:
|
|
1567
1676
|
metadata = version_response.json()
|
|
1568
1677
|
elif version_response.status_code in (404, 405):
|
|
1569
1678
|
health_response = await self._http.get(f"{self._server_url}/health")
|
|
1679
|
+
if self._destroyed:
|
|
1680
|
+
raise _destroyed_client_error()
|
|
1570
1681
|
if health_response.status_code != 200:
|
|
1571
1682
|
raise MemWalError(
|
|
1572
1683
|
"Walrus Memory compatibility check failed: "
|
|
@@ -1684,10 +1795,16 @@ class MemWal:
|
|
|
1684
1795
|
)
|
|
1685
1796
|
|
|
1686
1797
|
async def _build_seal_session_inner(self) -> str:
|
|
1798
|
+
if self._destroyed or self._signing_key is None:
|
|
1799
|
+
raise _destroyed_client_error()
|
|
1687
1800
|
cfg = await self._fetch_server_config()
|
|
1801
|
+
if self._destroyed or self._signing_key is None:
|
|
1802
|
+
raise _destroyed_client_error()
|
|
1688
1803
|
await self._assert_first_package_version(
|
|
1689
1804
|
cfg["network"], cfg["suiRpcUrl"], cfg["packageId"]
|
|
1690
1805
|
)
|
|
1806
|
+
if self._destroyed or self._signing_key is None:
|
|
1807
|
+
raise _destroyed_client_error()
|
|
1691
1808
|
|
|
1692
1809
|
session_signing_key = nacl.signing.SigningKey.generate()
|
|
1693
1810
|
session_public_key = bytes(session_signing_key.verify_key)
|
|
@@ -1716,6 +1833,8 @@ class MemWal:
|
|
|
1716
1833
|
separators=(",", ":"),
|
|
1717
1834
|
)
|
|
1718
1835
|
session_bytes = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
|
|
1836
|
+
if self._destroyed:
|
|
1837
|
+
raise _destroyed_client_error()
|
|
1719
1838
|
self._session_cache = (
|
|
1720
1839
|
session_bytes,
|
|
1721
1840
|
int(time.time() * 1000) + SEAL_SESSION_TTL_MIN * 60_000 - SEAL_SESSION_SAFETY_MARGIN_MS,
|
|
@@ -1723,22 +1842,40 @@ class MemWal:
|
|
|
1723
1842
|
return session_bytes
|
|
1724
1843
|
|
|
1725
1844
|
async def _build_seal_session(self) -> str:
|
|
1845
|
+
if self._destroyed:
|
|
1846
|
+
raise _destroyed_client_error()
|
|
1726
1847
|
now_ms = int(time.time() * 1000)
|
|
1727
1848
|
if self._session_cache is not None:
|
|
1728
1849
|
cached_bytes, expires_at_ms = self._session_cache
|
|
1729
1850
|
if now_ms < expires_at_ms:
|
|
1730
1851
|
return cached_bytes
|
|
1731
1852
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1853
|
+
task = self._session_build_task
|
|
1854
|
+
if task is None or task.done():
|
|
1855
|
+
task = asyncio.create_task(self._build_seal_session_inner())
|
|
1856
|
+
self._session_build_task = task
|
|
1857
|
+
|
|
1858
|
+
def _on_session_build_done(done_task: "asyncio.Task[str]") -> None:
|
|
1859
|
+
if self._session_build_task is done_task:
|
|
1860
|
+
self._session_build_task = None
|
|
1861
|
+
if not done_task.cancelled():
|
|
1862
|
+
done_task.exception()
|
|
1863
|
+
|
|
1864
|
+
task.add_done_callback(_on_session_build_done)
|
|
1734
1865
|
|
|
1735
|
-
self._session_build_task = asyncio.create_task(self._build_seal_session_inner())
|
|
1736
1866
|
try:
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
self.
|
|
1867
|
+
result = await asyncio.shield(task)
|
|
1868
|
+
except asyncio.CancelledError:
|
|
1869
|
+
if self._destroyed:
|
|
1870
|
+
raise _destroyed_client_error() from None
|
|
1871
|
+
raise
|
|
1872
|
+
if self._destroyed:
|
|
1873
|
+
raise _destroyed_client_error()
|
|
1874
|
+
return result
|
|
1740
1875
|
|
|
1741
1876
|
async def _resolve_owner_inner(self) -> str:
|
|
1877
|
+
if self._destroyed:
|
|
1878
|
+
raise _destroyed_client_error()
|
|
1742
1879
|
# POST /api/stats authenticates with the same delegate scheme and
|
|
1743
1880
|
# returns the owner the relayer resolved from our key. Same approach
|
|
1744
1881
|
# as the TypeScript SDK's resolveOwner().
|
|
@@ -1748,6 +1885,8 @@ class MemWal:
|
|
|
1748
1885
|
{"namespace": self._namespace},
|
|
1749
1886
|
include_seal_session=False,
|
|
1750
1887
|
)
|
|
1888
|
+
if self._destroyed:
|
|
1889
|
+
raise _destroyed_client_error()
|
|
1751
1890
|
owner = data.get("owner")
|
|
1752
1891
|
if not owner:
|
|
1753
1892
|
raise MemWalError(
|
|
@@ -1763,17 +1902,33 @@ class MemWal:
|
|
|
1763
1902
|
The owner-scoped read routes take the address in the path, but the
|
|
1764
1903
|
client is configured with only a delegate key and account id.
|
|
1765
1904
|
"""
|
|
1905
|
+
if self._destroyed:
|
|
1906
|
+
raise _destroyed_client_error()
|
|
1766
1907
|
if self._owner_address is not None:
|
|
1767
1908
|
return self._owner_address
|
|
1768
1909
|
|
|
1769
|
-
|
|
1770
|
-
|
|
1910
|
+
task = self._owner_task
|
|
1911
|
+
if task is None or task.done():
|
|
1912
|
+
task = asyncio.create_task(self._resolve_owner_inner())
|
|
1913
|
+
self._owner_task = task
|
|
1914
|
+
|
|
1915
|
+
def _on_owner_done(done_task: "asyncio.Task[str]") -> None:
|
|
1916
|
+
if self._owner_task is done_task:
|
|
1917
|
+
self._owner_task = None
|
|
1918
|
+
if not done_task.cancelled():
|
|
1919
|
+
done_task.exception()
|
|
1920
|
+
|
|
1921
|
+
task.add_done_callback(_on_owner_done)
|
|
1771
1922
|
|
|
1772
|
-
self._owner_task = asyncio.create_task(self._resolve_owner_inner())
|
|
1773
1923
|
try:
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
self.
|
|
1924
|
+
result = await asyncio.shield(task)
|
|
1925
|
+
except asyncio.CancelledError:
|
|
1926
|
+
if self._destroyed:
|
|
1927
|
+
raise _destroyed_client_error() from None
|
|
1928
|
+
raise
|
|
1929
|
+
if self._destroyed:
|
|
1930
|
+
raise _destroyed_client_error()
|
|
1931
|
+
return result
|
|
1777
1932
|
|
|
1778
1933
|
async def _signed_request(
|
|
1779
1934
|
self,
|
|
@@ -1803,8 +1958,12 @@ class MemWal:
|
|
|
1803
1958
|
- ``x-account-id``: Walrus Memory account object ID
|
|
1804
1959
|
- ``Content-Type``: application/json
|
|
1805
1960
|
"""
|
|
1961
|
+
if self._destroyed or self._signing_key is None:
|
|
1962
|
+
raise _destroyed_client_error()
|
|
1806
1963
|
|
|
1807
1964
|
await self._ensure_compatible_relayer()
|
|
1965
|
+
if self._destroyed or self._signing_key is None:
|
|
1966
|
+
raise _destroyed_client_error()
|
|
1808
1967
|
|
|
1809
1968
|
method_upper = method.upper()
|
|
1810
1969
|
timestamp = str(int(time.time()))
|
|
@@ -1838,6 +1997,8 @@ class MemWal:
|
|
|
1838
1997
|
}
|
|
1839
1998
|
if include_seal_session:
|
|
1840
1999
|
headers["x-seal-session"] = await self._build_seal_session()
|
|
2000
|
+
if self._destroyed or self._signing_key is None:
|
|
2001
|
+
raise _destroyed_client_error()
|
|
1841
2002
|
|
|
1842
2003
|
response = await self._http.request(
|
|
1843
2004
|
method=method_upper,
|
|
@@ -1846,6 +2007,8 @@ class MemWal:
|
|
|
1846
2007
|
content=None if method_upper == "GET" else body_str,
|
|
1847
2008
|
timeout=timeout,
|
|
1848
2009
|
)
|
|
2010
|
+
if self._destroyed or self._signing_key is None:
|
|
2011
|
+
raise _destroyed_client_error()
|
|
1849
2012
|
|
|
1850
2013
|
if response.status_code not in accepted_statuses:
|
|
1851
2014
|
err_text = response.text
|
|
@@ -2073,7 +2236,7 @@ class MemWalSync:
|
|
|
2073
2236
|
@classmethod
|
|
2074
2237
|
def create(
|
|
2075
2238
|
cls,
|
|
2076
|
-
key: str,
|
|
2239
|
+
key: Union[str, bytes, bytearray],
|
|
2077
2240
|
account_id: str,
|
|
2078
2241
|
server_url: Optional[str] = None,
|
|
2079
2242
|
namespace: str = "default",
|
|
@@ -2278,9 +2441,18 @@ class MemWalSync:
|
|
|
2278
2441
|
"""Synchronous version of :meth:`MemWal.get_public_key_hex`."""
|
|
2279
2442
|
return self._run(self._inner.get_public_key_hex())
|
|
2280
2443
|
|
|
2281
|
-
def
|
|
2282
|
-
"""
|
|
2283
|
-
self.
|
|
2444
|
+
def destroy(self) -> None:
|
|
2445
|
+
"""Synchronous version of :meth:`MemWal.destroy`."""
|
|
2446
|
+
self._inner.destroy()
|
|
2447
|
+
|
|
2448
|
+
def close(self, clear_keys: bool = False) -> None:
|
|
2449
|
+
"""Close the underlying HTTP client.
|
|
2450
|
+
|
|
2451
|
+
Args:
|
|
2452
|
+
clear_keys: If ``True``, also call :meth:`destroy` to wipe internal
|
|
2453
|
+
key buffers, drop signing credentials, and invalidate the client.
|
|
2454
|
+
"""
|
|
2455
|
+
self._run(self._inner.close(clear_keys=clear_keys))
|
|
2284
2456
|
|
|
2285
2457
|
def __enter__(self) -> "MemWalSync":
|
|
2286
2458
|
return self
|
memwal/mock.py
CHANGED
|
@@ -129,9 +129,13 @@ class MemWalMock:
|
|
|
129
129
|
) -> "MemWalMock":
|
|
130
130
|
return cls(namespace, owner, initial_memories)
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
def destroy(self) -> None:
|
|
133
133
|
"""No-op for API parity with :class:`MemWal`."""
|
|
134
134
|
|
|
135
|
+
async def close(self, clear_keys: bool = False) -> None:
|
|
136
|
+
"""No-op for API parity with :class:`MemWal`."""
|
|
137
|
+
del clear_keys
|
|
138
|
+
|
|
135
139
|
async def __aenter__(self) -> "MemWalMock":
|
|
136
140
|
return self
|
|
137
141
|
|
|
@@ -714,8 +718,11 @@ class MemWalMockSync:
|
|
|
714
718
|
def clear(self, namespace: Optional[str] = None) -> int:
|
|
715
719
|
return self._inner.clear(namespace)
|
|
716
720
|
|
|
717
|
-
def
|
|
718
|
-
self.
|
|
721
|
+
def destroy(self) -> None:
|
|
722
|
+
self._inner.destroy()
|
|
723
|
+
|
|
724
|
+
def close(self, clear_keys: bool = False) -> None:
|
|
725
|
+
self._run(self._inner.close(clear_keys=clear_keys))
|
|
719
726
|
|
|
720
727
|
def __enter__(self) -> "MemWalMockSync":
|
|
721
728
|
return self
|
memwal/types.py
CHANGED
|
@@ -9,7 +9,7 @@ the Walrus Memory Rust server (TEE).
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
-
from typing import Any, Dict, List, Optional
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
13
|
|
|
14
14
|
# ============================================================
|
|
15
15
|
# Config
|
|
@@ -34,8 +34,11 @@ class MemWalConfig:
|
|
|
34
34
|
"""Configuration for creating a Walrus Memory client.
|
|
35
35
|
|
|
36
36
|
Attributes:
|
|
37
|
-
key: Ed25519 private key (hex string
|
|
38
|
-
|
|
37
|
+
key: Ed25519 private key (hex string, ``suiprivkey1...``, or 32-byte
|
|
38
|
+
``bytes`` / ``bytearray``). This is the delegate key from the
|
|
39
|
+
Walrus Memory dashboard. When passed as ``bytearray``, the client
|
|
40
|
+
copies the bytes into its own internal buffer so :meth:`MemWal.destroy`
|
|
41
|
+
zeroes only the client's copy.
|
|
39
42
|
account_id: Walrus Memory account object ID on Sui.
|
|
40
43
|
server_url: Server URL. ``None`` means omitted. Any explicit URL,
|
|
41
44
|
including the hosted default, wins over ``env``.
|
|
@@ -46,7 +49,7 @@ class MemWalConfig:
|
|
|
46
49
|
> ``DEFAULT_SERVER_URL``.
|
|
47
50
|
"""
|
|
48
51
|
|
|
49
|
-
key: str
|
|
52
|
+
key: Union[str, bytes, bytearray]
|
|
50
53
|
account_id: str
|
|
51
54
|
server_url: Optional[str] = None
|
|
52
55
|
namespace: str = "default"
|
memwal/utils.py
CHANGED
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import base64
|
|
11
11
|
import hashlib
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
|
-
from typing import Tuple
|
|
13
|
+
from typing import Tuple, Union
|
|
14
14
|
|
|
15
15
|
import nacl.signing
|
|
16
16
|
|
|
@@ -51,7 +51,7 @@ def hex_to_bytes(hex_str: str) -> bytes:
|
|
|
51
51
|
return bytes.fromhex(clean)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def bytes_to_hex(b: bytes) -> str:
|
|
54
|
+
def bytes_to_hex(b: Union[bytes, bytearray]) -> str:
|
|
55
55
|
"""Convert bytes to a lowercase hex string (no ``0x`` prefix).
|
|
56
56
|
|
|
57
57
|
Args:
|
|
@@ -75,11 +75,14 @@ def sha256_hex(data: str) -> str:
|
|
|
75
75
|
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
def build_signing_key(
|
|
79
|
-
|
|
78
|
+
def build_signing_key(
|
|
79
|
+
private_key: Union[str, bytes, bytearray]
|
|
80
|
+
) -> nacl.signing.SigningKey:
|
|
81
|
+
"""Build a PyNaCl ``SigningKey`` from an Ed25519 private key hex string or 32-byte buffer.
|
|
80
82
|
|
|
81
83
|
Args:
|
|
82
|
-
|
|
84
|
+
private_key: 32-byte Ed25519 seed as hex (64 hex chars, optionally ``0x``-prefixed)
|
|
85
|
+
or raw 32-byte ``bytes`` / ``bytearray``.
|
|
83
86
|
|
|
84
87
|
Returns:
|
|
85
88
|
A ``nacl.signing.SigningKey`` instance.
|
|
@@ -87,7 +90,10 @@ def build_signing_key(private_key_hex: str) -> nacl.signing.SigningKey:
|
|
|
87
90
|
Raises:
|
|
88
91
|
ValueError: If the decoded seed is not exactly 32 bytes.
|
|
89
92
|
"""
|
|
90
|
-
|
|
93
|
+
if isinstance(private_key, (bytes, bytearray)):
|
|
94
|
+
seed_bytes = bytes(private_key)
|
|
95
|
+
else:
|
|
96
|
+
seed_bytes = hex_to_bytes(private_key)
|
|
91
97
|
if len(seed_bytes) != 32:
|
|
92
98
|
raise ValueError(
|
|
93
99
|
f"Ed25519 seed must be exactly 32 bytes, got {len(seed_bytes)}"
|
|
@@ -290,12 +296,19 @@ def decode_sui_private_key(encoded: str) -> bytes:
|
|
|
290
296
|
return seed
|
|
291
297
|
|
|
292
298
|
|
|
293
|
-
def normalize_private_key(key: str) -> str:
|
|
294
|
-
"""Accept
|
|
299
|
+
def normalize_private_key(key: Union[str, bytes, bytearray]) -> str:
|
|
300
|
+
"""Accept a hex seed, ``suiprivkey1...`` string, or 32-byte ``bytes``/``bytearray``; return hex.
|
|
295
301
|
|
|
296
|
-
The TypeScript SDK
|
|
297
|
-
so hex-only input would reject a key a user reasonably expects to work.
|
|
302
|
+
The TypeScript SDK accepts hex, ``suiprivkey1...``, and 32-byte ``Uint8Array``.
|
|
298
303
|
"""
|
|
304
|
+
if isinstance(key, (bytes, bytearray)):
|
|
305
|
+
if len(key) != 32:
|
|
306
|
+
raise ValueError(f"Ed25519 seed must be exactly 32 bytes, got {len(key)}")
|
|
307
|
+
return bytes_to_hex(key)
|
|
308
|
+
if not isinstance(key, str):
|
|
309
|
+
raise TypeError(
|
|
310
|
+
f"key must be str, bytes, or bytearray, got {type(key).__name__}"
|
|
311
|
+
)
|
|
299
312
|
candidate = key.strip()
|
|
300
313
|
if candidate.lower().startswith("suiprivkey1"):
|
|
301
314
|
return bytes_to_hex(decode_sui_private_key(candidate))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2.dev1
|
|
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
|
|
@@ -259,6 +259,16 @@ Create a new async client.
|
|
|
259
259
|
| `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
|
|
260
260
|
| `await recall_manual(opts)` | Search with pre-computed vector |
|
|
261
261
|
| `await get_public_key_hex()` | Get Ed25519 public key |
|
|
262
|
+
| `destroy()` | Zero internal key seed buffer, drop signing credentials/session cache, and permanently invalidate the client |
|
|
263
|
+
| `await close(clear_keys=False)` | Close the underlying HTTP client (pass `clear_keys=True` to also run `destroy()`) |
|
|
264
|
+
|
|
265
|
+
### Key Lifecycle & `destroy()`
|
|
266
|
+
|
|
267
|
+
For parity with the TypeScript SDK's `destroy()`, `MemWal` and `MemWalSync` expose `destroy()` and `close(clear_keys=True)`:
|
|
268
|
+
|
|
269
|
+
- `key` may be passed as a hex string, `suiprivkey1...` string, or a 32-byte `bytes` / `bytearray`. When passed as `bytearray`, the SDK copies the bytes into its own internal buffer so `destroy()` zeroes only the client's copy and leaves the caller's `bytearray` untouched.
|
|
270
|
+
- Calling `destroy()` (or `await client.close(clear_keys=True)` / `sync_client.close(clear_keys=True)`) zero-fills the client's internal `bytearray` seed copy in place, clears `_private_key_hex`, drops the `_signing_key` and cached SEAL session references, cancels any in-flight initialization tasks, and causes subsequent or in-flight calls to raise `MemWalError`.
|
|
271
|
+
- **CPython memory semantics**: Python `str` and `bytes` objects (and PyNaCl's internal C-level `SigningKey` buffers) are immutable in CPython and cannot be overwritten in place from pure Python. `destroy()` zeroes the mutable `bytearray` copy and drops all references to immutable key objects so they become eligible for garbage collection once the caller also releases its references. To minimize plaintext `str` copies in process memory, pass a 32-byte `bytearray` to `MemWal.create()` and zero your own buffer after creation.
|
|
262
272
|
|
|
263
273
|
## Authentication
|
|
264
274
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
memwal/__init__.py,sha256=MGoDtX1cNKJndeJGln3gaFCfk3ueuHt0Mfs-CF2JyyE,3142
|
|
2
|
+
memwal/client.py,sha256=6YExSuMZ9RGeIShakk8UxSXgVY8pKwDexExPgnqhH4I,96563
|
|
3
|
+
memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
|
|
4
|
+
memwal/middleware.py,sha256=z2xdbvaobBg4byJXnrX7amWdVYE4jfeO4t31DoDBv_o,38413
|
|
5
|
+
memwal/mock.py,sha256=cB-mu2yqycvMCVMmgqTTsIj_pGSkHufCyLNbfkPdHEE,24262
|
|
6
|
+
memwal/types.py,sha256=fPvxU4k14qmj577sMtGZyVaGhwXxORtyXL-TxD0P6LI,14002
|
|
7
|
+
memwal/utils.py,sha256=YyLuxPjKB0RksL1ZGPORpW7QrSID0vQQABq3L_64zKw,12596
|
|
8
|
+
memwal-0.2.2.dev1.dist-info/METADATA,sha256=XLsl3x9kG7nJN5MyuQ_hg7te9FZbu59qLwlbUyyQQEM,11923
|
|
9
|
+
memwal-0.2.2.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
memwal-0.2.2.dev1.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
memwal/__init__.py,sha256=IAJWdWRnUVTD5bZN7EVWQ6Q511j27npZgD5aG_DMltk,3142
|
|
2
|
-
memwal/client.py,sha256=GryxHSCyGjDmlWOTBSJp7J7ItopwUs0D1JdynrgjYTk,88398
|
|
3
|
-
memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
|
|
4
|
-
memwal/middleware.py,sha256=z2xdbvaobBg4byJXnrX7amWdVYE4jfeO4t31DoDBv_o,38413
|
|
5
|
-
memwal/mock.py,sha256=5bXVej1Xs1a3o1tLL0Vvr1QB9IXyRUTBM7qooTEtysQ,24015
|
|
6
|
-
memwal/types.py,sha256=xvs9BqFLXDsfkHp3-8Bd_Pv5RhDDWOKr0-bUsvic1P4,13732
|
|
7
|
-
memwal/utils.py,sha256=Dyv-DWIMwivtYLxruHXlNXE9i8hZu8QDv1TapVKMeOQ,12064
|
|
8
|
-
memwal-0.2.1.dev0.dist-info/METADATA,sha256=OiHVIIM8Tbpf4Mj0dsKObxQfxZD_1NqshTsKGthp9hE,10346
|
|
9
|
-
memwal-0.2.1.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
-
memwal-0.2.1.dev0.dist-info/RECORD,,
|
|
File without changes
|