memwal 0.2.2.dev2__tar.gz → 0.2.2.dev4__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 (43) hide show
  1. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/CHANGELOG.md +2 -0
  2. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/PKG-INFO +4 -3
  3. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/README.md +3 -2
  4. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/__init__.py +5 -1
  5. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/client.py +130 -11
  6. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/compatibility.py +6 -0
  7. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/mock.py +11 -0
  8. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/types.py +9 -1
  9. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/notebooks/walrus_memory_python_sdk.ipynb +2 -2
  10. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/pyproject.toml +1 -1
  11. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/run_tests.py +1 -1
  12. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_env_presets.py +2 -2
  13. memwal-0.2.2.dev4/tests/test_forget.py +91 -0
  14. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_integration.py +20 -19
  15. memwal-0.2.2.dev4/tests/test_json_body.py +310 -0
  16. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_mock.py +24 -1
  17. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/.gitignore +0 -0
  18. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/.env.example +0 -0
  19. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/.gitignore +0 -0
  20. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/async_remember_demo.py +0 -0
  21. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/interactive_demo.py +0 -0
  22. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/verify_credentials.py +0 -0
  23. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/middleware.py +0 -0
  24. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/utils.py +0 -0
  25. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/__init__.py +0 -0
  26. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_ask_validation.py +0 -0
  27. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_auth_rejected_message.py +0 -0
  28. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_blank_text.py +0 -0
  29. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_client.py +0 -0
  30. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_destroy_and_cancel_shield.py +0 -0
  31. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_empty_namespace.py +0 -0
  32. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_list_namespaces.py +0 -0
  33. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_middleware.py +0 -0
  34. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_missing_response_fields.py +0 -0
  35. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_nonfinite_validation.py +0 -0
  36. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_normalize_server_url.py +0 -0
  37. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_polling_delay.py +0 -0
  38. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_recall_validation.py +0 -0
  39. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_recall_weights.py +0 -0
  40. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_remember_derived_key.py +0 -0
  41. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_retry_after_http_date.py +0 -0
  42. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_signing.py +0 -0
  43. {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_transport_retry.py +0 -0
@@ -6,10 +6,12 @@ Unreleased.
6
6
 
7
7
  ### Added
8
8
 
9
+ - `forget(namespace=None)` (and alias `forget_namespace(namespace=None)`) on `MemWal` and `MemWalSync` wraps `POST /api/forget` and returns `ForgetResult` (`deleted`, `namespace`, `owner`). It deletes the owner's index rows for the namespace while leaving Walrus blobs in place. `MemWalMock` and `MemWalMockSync` retain synchronous per-blob `forget(blob_id) -> bool` and add `forget_namespace(namespace=None) -> ForgetResult` for namespace erasure. (#1061)
9
10
  - `MemWal.destroy()` and `MemWalSync.destroy()` (plus `close(clear_keys=True)`) zero the client's internal `bytearray` seed copy in place, clear `_private_key_hex`, drop `_signing_key` and cached SEAL session credentials, cancel in-flight initialization tasks, and reject any subsequent or in-flight operation with `MemWalError`. `MemWal.create()` and `MemWalSync.create()` also accept 32-byte `bytes` / `bytearray` keys and copy caller `bytearray` buffers so `destroy()` leaves the caller's buffer intact. (#975)
10
11
 
11
12
  ### Fixed
12
13
 
14
+ - `health()`, `compatibility()`, `_fetch_server_config()`, package-version preflight probes, and `_signed_request()` raise `MemWalJsonBodyError` (a `MemWalError` subclass carrying `.status`, `.endpoint`, and `.body`) instead of Python's raw `json.JSONDecodeError` when a 2xx response has an empty or non-JSON body. (#1219)
13
15
  - Shield shared `_build_seal_session` and `_resolve_owner` initialization tasks with `asyncio.shield`, clear the shared task slot via a completion callback that consumes exceptions on orphaned tasks, and cancel/drain in-flight initialization tasks in `close()` so cancelling one concurrent caller does not abort sibling callers or leave unretrieved task exceptions. (#1029)
14
16
  - `health`, `ask`, `restore`, `list_namespaces`, `remember_manual`, and `recall` raise `MemWalError` naming the endpoint and field when a 2xx response omits a required field. They used to raise a bare `KeyError` that callers catching `MemWalError` never saw. (#1250, #1256)
15
17
  - `ask()` rejects a blank `question` and a `limit` that is not a positive integer before it signs the request. A NaN `limit` used to serialize as an invalid-JSON `NaN` token, and negative or fractional values were sent as-is. (#1249, #1255)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.2.2.dev2
3
+ Version: 0.2.2.dev4
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
@@ -147,7 +147,7 @@ async def test_memory_flow():
147
147
  assert "dark mode" in result.results[0].text
148
148
  ```
149
149
 
150
- 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.
150
+ The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)` (synchronous per-blob deletion returning `bool`), `forget_namespace(namespace?)` (namespace erasure returning `ForgetResult`), 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.
151
151
 
152
152
  ### Context Manager
153
153
 
@@ -181,7 +181,7 @@ memwal = MemWal.create(
181
181
  |-------|-------------|
182
182
  | `prod` | `https://relayer.memory.walrus.xyz` |
183
183
  | `dev` | `https://relayer.dev.memwal.ai` |
184
- | `staging` | `https://relayer-staging.memory.walrus.xyz` |
184
+ | `staging` | `https://relayer.dev.memwal.ai` |
185
185
 
186
186
  Precedence: any explicit **`server_url` wins over `env`**, including the hosted
187
187
  default. `env` applies only when `server_url` is omitted (`None`). An unknown
@@ -254,6 +254,7 @@ Create a new async client.
254
254
  | `await analyze(text, namespace?)` | Extract and store facts |
255
255
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
256
256
  | `await restore(namespace, limit?)` | Restore a namespace |
257
+ | `await forget(namespace?)` | Delete index rows for the namespace. Walrus blobs stay. Returns `ForgetResult`. |
257
258
  | `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
258
259
  | `await health()` | Check server health |
259
260
  | `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
@@ -106,7 +106,7 @@ async def test_memory_flow():
106
106
  assert "dark mode" in result.results[0].text
107
107
  ```
108
108
 
109
- 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.
109
+ The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)` (synchronous per-blob deletion returning `bool`), `forget_namespace(namespace?)` (namespace erasure returning `ForgetResult`), 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.
110
110
 
111
111
  ### Context Manager
112
112
 
@@ -140,7 +140,7 @@ memwal = MemWal.create(
140
140
  |-------|-------------|
141
141
  | `prod` | `https://relayer.memory.walrus.xyz` |
142
142
  | `dev` | `https://relayer.dev.memwal.ai` |
143
- | `staging` | `https://relayer-staging.memory.walrus.xyz` |
143
+ | `staging` | `https://relayer.dev.memwal.ai` |
144
144
 
145
145
  Precedence: any explicit **`server_url` wins over `env`**, including the hosted
146
146
  default. `env` applies only when `server_url` is omitted (`None`). An unknown
@@ -213,6 +213,7 @@ Create a new async client.
213
213
  | `await analyze(text, namespace?)` | Extract and store facts |
214
214
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
215
215
  | `await restore(namespace, limit?)` | Restore a namespace |
216
+ | `await forget(namespace?)` | Delete index rows for the namespace. Walrus blobs stay. Returns `ForgetResult`. |
216
217
  | `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
217
218
  | `await health()` | Check server health |
218
219
  | `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
@@ -27,6 +27,7 @@ from .client import (
27
27
  MemWalClockDriftError,
28
28
  MemWalCompatibilityError,
29
29
  MemWalError,
30
+ MemWalJsonBodyError,
30
31
  MemWalRateLimited,
31
32
  MemWalRememberJobFailed,
32
33
  MemWalRememberJobNotFound,
@@ -43,6 +44,7 @@ from .types import (
43
44
  AskMemory,
44
45
  AskResult,
45
46
  EmbedResult,
47
+ ForgetResult,
46
48
  HealthResult,
47
49
  MemWalConfig,
48
50
  NamespacesResult,
@@ -83,6 +85,7 @@ __all__ = [
83
85
  "MemWalError",
84
86
  "MemWalCompatibilityError",
85
87
  "MemWalClockDriftError",
88
+ "MemWalJsonBodyError",
86
89
  "MemWalRateLimited",
87
90
  "MemWalRememberJobFailed",
88
91
  "MemWalRememberJobNotFound",
@@ -118,6 +121,7 @@ __all__ = [
118
121
  "AnalyzedFact",
119
122
  "HealthResult",
120
123
  "RestoreResult",
124
+ "ForgetResult",
121
125
  "NamespaceSummary",
122
126
  "NamespacesResult",
123
127
  "ScoringWeights",
@@ -128,4 +132,4 @@ __all__ = [
128
132
  "RecallManualResult",
129
133
  ]
130
134
 
131
- __version__ = "0.2.2.dev2"
135
+ __version__ = "0.2.2.dev4"
@@ -51,6 +51,7 @@ from .types import (
51
51
  AskMemory,
52
52
  AskResult,
53
53
  EmbedResult,
54
+ ForgetResult,
54
55
  HealthResult,
55
56
  MemWalConfig,
56
57
  NamespacesResult,
@@ -675,7 +676,7 @@ class MemWal:
675
676
  if generated_key:
676
677
  self._pending_remember_keys.pop(request_identity, None)
677
678
  return RememberAcceptedResult(
678
- job_id=data["job_id"],
679
+ job_id=_require(data, "job_id", "POST /api/remember"),
679
680
  status=data.get("status", "pending"),
680
681
  )
681
682
 
@@ -717,6 +718,10 @@ class MemWal:
717
718
  # Status is metadata. No SEAL decrypt, so no session. Matches TS.
718
719
  include_seal_session=False,
719
720
  )
721
+ except MemWalJsonBodyError as err:
722
+ if err.status != 404:
723
+ raise
724
+ data = None
720
725
  except (json.JSONDecodeError, UnicodeDecodeError):
721
726
  data = None
722
727
  if not isinstance(data, dict):
@@ -800,6 +805,10 @@ class MemWal:
800
805
  )
801
806
  else:
802
807
  raise
808
+ except MemWalJsonBodyError as err:
809
+ if err.status == 404:
810
+ raise MemWalRememberJobNotFound(job_id) from None
811
+ raise
803
812
  except (json.JSONDecodeError, UnicodeDecodeError):
804
813
  raise MemWalRememberJobNotFound(job_id) from None
805
814
  except _HttpStatusError as err:
@@ -1244,6 +1253,30 @@ class MemWal:
1244
1253
  dropped_count=dropped,
1245
1254
  )
1246
1255
 
1256
+ async def forget(self, namespace: Optional[str] = None) -> ForgetResult:
1257
+ """Delete this owner's index rows for ``namespace``.
1258
+
1259
+ Wraps ``POST /api/forget``. Walrus blobs persist; the rows stop being
1260
+ retrievable. Defaults to the client namespace.
1261
+ """
1262
+ resolved_namespace = self._resolve_namespace(namespace)
1263
+ data = await self._signed_request(
1264
+ "POST",
1265
+ "/api/forget",
1266
+ {"namespace": resolved_namespace},
1267
+ include_seal_session=False,
1268
+ )
1269
+ endpoint = "POST /api/forget"
1270
+ return ForgetResult(
1271
+ deleted=int(_require(data, "deleted", endpoint)),
1272
+ namespace=_require(data, "namespace", endpoint),
1273
+ owner=_require(data, "owner", endpoint),
1274
+ )
1275
+
1276
+ async def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
1277
+ """Alias for :meth:`forget` matching :meth:`MemWalMock.forget_namespace`."""
1278
+ return await self.forget(namespace)
1279
+
1247
1280
  async def analyze(
1248
1281
  self,
1249
1282
  text: str,
@@ -1562,7 +1595,7 @@ class MemWal:
1562
1595
  raise _destroyed_client_error()
1563
1596
  if response.status_code != 200:
1564
1597
  raise MemWalError(f"Health check failed: {response.status_code}")
1565
- data = response.json()
1598
+ data = _parse_json_response(response, "GET /health")
1566
1599
  return HealthResult(
1567
1600
  status=_require(data, "status", "GET /health"),
1568
1601
  version=_require(data, "version", "GET /health"),
@@ -1690,7 +1723,7 @@ class MemWal:
1690
1723
  if self._destroyed:
1691
1724
  raise _destroyed_client_error()
1692
1725
  if version_response.status_code == 200:
1693
- metadata = version_response.json()
1726
+ metadata = _parse_json_response(version_response, "GET /version")
1694
1727
  elif version_response.status_code in (404, 405):
1695
1728
  health_response = await self._http.get(f"{self._server_url}/health")
1696
1729
  if self._destroyed:
@@ -1701,7 +1734,7 @@ class MemWal:
1701
1734
  f"GET /version returned {version_response.status_code}, "
1702
1735
  f"and GET /health returned {health_response.status_code}"
1703
1736
  )
1704
- metadata = health_response.json()
1737
+ metadata = _parse_json_response(health_response, "GET /health")
1705
1738
  else:
1706
1739
  raise MemWalError(
1707
1740
  "Walrus Memory compatibility check failed: "
@@ -1723,7 +1756,11 @@ class MemWal:
1723
1756
  if response.status_code != 200:
1724
1757
  raise MemWalError(f"GET /config returned {response.status_code}")
1725
1758
 
1726
- data = response.json()
1759
+ data = _parse_json_response(response, "GET /config")
1760
+ if not isinstance(data, dict):
1761
+ raise MemWalError(
1762
+ f"GET /config response was {type(data).__name__}, expected an object"
1763
+ )
1727
1764
  package_id = data.get("packageId")
1728
1765
  network = data.get("network")
1729
1766
  sui_rpc_url = data.get("suiRpcUrl")
@@ -1758,8 +1795,9 @@ class MemWal:
1758
1795
  )
1759
1796
  if response.status_code != 200:
1760
1797
  raise MemWalError(f"Sui GraphQL returned {response.status_code}")
1761
- body = response.json()
1762
- obj = (body.get("data") or {}).get("object")
1798
+ body = _parse_json_response(response, f"POST {graphql_url}")
1799
+ data = body.get("data") if isinstance(body, dict) else None
1800
+ obj = data.get("object") if isinstance(data, dict) else None
1763
1801
  if not isinstance(obj, dict) or obj.get("version") is None:
1764
1802
  raise MemWalError(f"Sui GraphQL could not resolve package {package_id}")
1765
1803
  return obj["version"]
@@ -1780,8 +1818,8 @@ class MemWal:
1780
1818
  if response.status_code != 200:
1781
1819
  raise MemWalError(f"sui_getObject returned {response.status_code}")
1782
1820
 
1783
- body = response.json()
1784
- result = body.get("result", {})
1821
+ body = _parse_json_response(response, f"POST {sui_rpc_url}")
1822
+ result = body.get("result", {}) if isinstance(body, dict) else {}
1785
1823
  version = None
1786
1824
  if isinstance(result, dict):
1787
1825
  data = result.get("data")
@@ -1904,7 +1942,7 @@ class MemWal:
1904
1942
  )
1905
1943
  if self._destroyed:
1906
1944
  raise _destroyed_client_error()
1907
- owner = data.get("owner")
1945
+ owner = data.get("owner") if isinstance(data, dict) else None
1908
1946
  if not owner:
1909
1947
  raise MemWalError(
1910
1948
  "Walrus Memory could not resolve this account's owner address "
@@ -2052,7 +2090,15 @@ class MemWal:
2052
2090
  retry_after=response.headers.get("retry-after"),
2053
2091
  )
2054
2092
 
2055
- return response.json()
2093
+ endpoint = f"{method_upper} {path}"
2094
+ data = _parse_json_response(response, endpoint)
2095
+ if response.status_code != 404 and not isinstance(data, dict):
2096
+ raise MemWalJsonBodyError(
2097
+ status=response.status_code,
2098
+ endpoint=endpoint,
2099
+ body=response.text,
2100
+ )
2101
+ return data
2056
2102
 
2057
2103
 
2058
2104
  class MemWalError(Exception):
@@ -2076,6 +2122,71 @@ class MemWalClockDriftError(MemWalError):
2076
2122
  pass
2077
2123
 
2078
2124
 
2125
+ class MemWalJsonBodyError(MemWalError):
2126
+ """Raised when an HTTP response that should carry JSON has an empty or non-JSON body."""
2127
+
2128
+ def __init__(
2129
+ self,
2130
+ status: int,
2131
+ endpoint: str,
2132
+ body: str,
2133
+ *,
2134
+ empty: bool = False,
2135
+ ) -> None:
2136
+ redacted = _redact_internal_urls(body.strip()[:200]) if body else ""
2137
+ if empty:
2138
+ message = (
2139
+ f"MemWal ({endpoint}): HTTP {status} response had an empty body; "
2140
+ "expected JSON"
2141
+ )
2142
+ elif redacted:
2143
+ message = (
2144
+ f"MemWal ({endpoint}): HTTP {status} response was not JSON: "
2145
+ f"{redacted}"
2146
+ )
2147
+ else:
2148
+ message = f"MemWal ({endpoint}): HTTP {status} response was not JSON"
2149
+ super().__init__(message)
2150
+ self.status = status
2151
+ self.endpoint = endpoint
2152
+ self.body = body
2153
+
2154
+
2155
+ def _parse_json_response(response: httpx.Response, endpoint: str) -> Any:
2156
+ """Decode a JSON response body or raise :class:`MemWalJsonBodyError` (#1219).
2157
+
2158
+ Mirrors the TypeScript SDK's ``jsonBodyError`` guard (#1201 / #1207): an
2159
+ empty or non-JSON 2xx response from a relayer or proxy in front of one
2160
+ surfaces as a :class:`MemWalError` naming the endpoint and HTTP status
2161
+ rather than Python's raw :class:`json.JSONDecodeError` or
2162
+ :class:`UnicodeDecodeError`.
2163
+ """
2164
+ try:
2165
+ return response.json()
2166
+ except UnicodeDecodeError as exc:
2167
+ raise MemWalJsonBodyError(
2168
+ status=response.status_code,
2169
+ endpoint=endpoint,
2170
+ body="",
2171
+ ) from exc
2172
+ except (json.JSONDecodeError, ValueError) as exc:
2173
+ doc = getattr(exc, "doc", None)
2174
+ if isinstance(doc, str):
2175
+ raw = doc
2176
+ else:
2177
+ try:
2178
+ raw = response.text
2179
+ except UnicodeDecodeError:
2180
+ raw = ""
2181
+ is_empty = not raw or not raw.strip()
2182
+ raise MemWalJsonBodyError(
2183
+ status=response.status_code,
2184
+ endpoint=endpoint,
2185
+ body=raw,
2186
+ empty=is_empty,
2187
+ ) from exc
2188
+
2189
+
2079
2190
  def _redact_internal_urls(text: str) -> str:
2080
2191
  return re.sub(
2081
2192
  r"https?://(?:(?:[\w-]+\.)*localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\]|0\.0\.0\.0)(?::\d+)?[^\s)\]>'\"]*",
@@ -2396,6 +2507,14 @@ class MemWalSync:
2396
2507
  :class:`RecallParams` for the recommended object-style call)."""
2397
2508
  return self._run(self._inner.recall(query, limit, namespace, max_distance))
2398
2509
 
2510
+ def forget(self, namespace: Optional[str] = None) -> ForgetResult:
2511
+ """Synchronous version of :meth:`MemWal.forget`."""
2512
+ return self._run(self._inner.forget(namespace))
2513
+
2514
+ def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
2515
+ """Synchronous version of :meth:`MemWal.forget_namespace`."""
2516
+ return self._run(self._inner.forget_namespace(namespace))
2517
+
2399
2518
  def analyze(
2400
2519
  self,
2401
2520
  text: str,
@@ -12,6 +12,12 @@ SUPPORTED_RELAYER_API_MAJOR = 1
12
12
  def compatibility_error(metadata: Dict[str, Any], server_url: str) -> Optional[str]:
13
13
  """Return an actionable error string when relayer metadata is unsupported."""
14
14
 
15
+ if not isinstance(metadata, dict):
16
+ return (
17
+ f"Walrus Memory relayer at {server_url} does not expose compatibility metadata. "
18
+ "Upgrade the relayer to a version that serves GET /version, or use an older SDK."
19
+ )
20
+
15
21
  api_version = metadata.get("apiVersion")
16
22
  relayer_version = metadata.get("relayerVersion")
17
23
  min_supported = metadata.get("minSupportedSdk")
@@ -20,6 +20,7 @@ from .types import (
20
20
  AskMemory,
21
21
  AskResult,
22
22
  EmbedResult,
23
+ ForgetResult,
23
24
  HealthResult,
24
25
  NamespacesResult,
25
26
  NamespaceSummary,
@@ -497,6 +498,13 @@ class MemWalMock:
497
498
  return True
498
499
  return False
499
500
 
501
+ async def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
502
+ resolved = self._namespace if namespace is None else namespace
503
+ if not resolved:
504
+ raise ValueError("namespace cannot be empty")
505
+ deleted = self.clear(resolved)
506
+ return ForgetResult(deleted=deleted, namespace=resolved, owner=self._owner)
507
+
500
508
  def clear(self, namespace: Optional[str] = None) -> int:
501
509
  removed = [
502
510
  memory
@@ -717,6 +725,9 @@ class MemWalMockSync:
717
725
  def forget(self, blob_id: str) -> bool:
718
726
  return self._inner.forget(blob_id)
719
727
 
728
+ def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
729
+ return self._run(self._inner.forget_namespace(namespace))
730
+
720
731
  def clear(self, namespace: Optional[str] = None) -> int:
721
732
  return self._inner.clear(namespace)
722
733
 
@@ -24,7 +24,7 @@ DEFAULT_SERVER_URL = "https://relayer.memory.walrus.xyz"
24
24
  ENV_PRESETS = {
25
25
  "prod": "https://relayer.memory.walrus.xyz",
26
26
  "dev": "https://relayer.dev.memwal.ai",
27
- "staging": "https://relayer-staging.memory.walrus.xyz",
27
+ "staging": "https://relayer.dev.memwal.ai",
28
28
  "local": "http://127.0.0.1:8000",
29
29
  }
30
30
 
@@ -84,6 +84,14 @@ class RememberResult:
84
84
  namespace: str
85
85
 
86
86
 
87
+ @dataclass
88
+ class ForgetResult:
89
+ """Result from POST /api/forget. Walrus blobs are not deleted."""
90
+
91
+ deleted: int
92
+ namespace: str
93
+ owner: str
94
+
87
95
  @dataclass
88
96
  class RecallMemory:
89
97
  """A single recalled memory."""
@@ -30,7 +30,7 @@
30
30
  "- optional OpenAI and LangChain middleware examples\n",
31
31
  "- cleanup and troubleshooting\n",
32
32
  "\n",
33
- "The notebook defaults to `staging` for test credentials and uses Railway-backed Walrus Memory domains: `https://relayer-staging.memory.walrus.xyz` for staging and `https://relayer.memory.walrus.xyz` for production. You can switch to `prod` when you have production credentials.\n"
33
+ "The notebook defaults to `staging` for test credentials and uses Railway-backed Walrus Memory domains: `https://relayer.dev.memwal.ai` for staging and `https://relayer.memory.walrus.xyz` for production. You can switch to `prod` when you have production credentials.\n"
34
34
  ]
35
35
  },
36
36
  {
@@ -91,7 +91,7 @@
91
91
  "\n",
92
92
  "ALLOWED_ENVS = {\"staging\", \"prod\"}\n",
93
93
  "SERVER_URL_PRESETS = {\n",
94
- " \"staging\": \"https://relayer-staging.memory.walrus.xyz\",\n",
94
+ " \"staging\": \"https://relayer.dev.memwal.ai\",\n",
95
95
  " \"prod\": \"https://relayer.memory.walrus.xyz\",\n",
96
96
  "}\n",
97
97
  "DEFAULT_ENV = \"staging\"\n",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.2.2.dev2"
7
+ version = "0.2.2.dev4"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,7 +26,7 @@ BOLD = "\033[1m"
26
26
  DIM = "\033[2m"
27
27
  RESET = "\033[0m"
28
28
 
29
- SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer-staging.memory.walrus.xyz")
29
+ SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer.dev.memwal.ai")
30
30
  PRIVATE_KEY = os.environ.get("MEMWAL_PRIVATE_KEY", "")
31
31
  ACCOUNT_ID = os.environ.get("MEMWAL_ACCOUNT_ID", "")
32
32
  HAS_KEY = bool(PRIVATE_KEY and ACCOUNT_ID)
@@ -19,7 +19,7 @@ ACCOUNT = "0x" + "ab" * 32
19
19
  [
20
20
  ("prod", "https://relayer.memory.walrus.xyz"),
21
21
  ("dev", "https://relayer.dev.memwal.ai"),
22
- ("staging", "https://relayer-staging.memory.walrus.xyz"),
22
+ ("staging", "https://relayer.dev.memwal.ai"),
23
23
  ("local", "http://127.0.0.1:8000"),
24
24
  ],
25
25
  )
@@ -52,7 +52,7 @@ def test_unknown_env_raises():
52
52
 
53
53
  def test_create_threads_env_through_to_client():
54
54
  client = MemWal.create(key=KEY, account_id=ACCOUNT, env="staging")
55
- assert client._server_url == "https://relayer-staging.memory.walrus.xyz"
55
+ assert client._server_url == "https://relayer.dev.memwal.ai"
56
56
 
57
57
 
58
58
  def test_sync_create_threads_env_through():
@@ -0,0 +1,91 @@
1
+ import pytest
2
+
3
+ from memwal import ForgetResult, MemWal, MemWalSync
4
+
5
+
6
+ class CaptureMemWal(MemWal):
7
+ def __init__(self, *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+ self.captured = []
10
+
11
+ async def _ensure_compatible_relayer(self):
12
+ return {
13
+ "apiVersion": "1.0.0",
14
+ "relayerVersion": "capture",
15
+ "minSupportedSdk": {"python": "0.0.0"},
16
+ }
17
+
18
+ async def _signed_request(
19
+ self,
20
+ method,
21
+ path,
22
+ body,
23
+ accepted_statuses=(200,),
24
+ include_seal_session=True,
25
+ *args,
26
+ **kwargs,
27
+ ):
28
+ self.captured.append((method, path, body))
29
+ return {"deleted": 2, "namespace": body["namespace"], "owner": "0xowner"}
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_forget_posts_the_client_namespace():
34
+ client = CaptureMemWal.create(
35
+ key="11" * 32,
36
+ account_id="0x" + "ab" * 32,
37
+ server_url="https://relayer.example",
38
+ namespace="profile",
39
+ )
40
+ result = await client.forget()
41
+ assert result == ForgetResult(deleted=2, namespace="profile", owner="0xowner")
42
+ alias = await client.forget_namespace("custom")
43
+ assert alias == ForgetResult(deleted=2, namespace="custom", owner="0xowner")
44
+ assert client.captured == [
45
+ ("POST", "/api/forget", {"namespace": "profile"}),
46
+ ("POST", "/api/forget", {"namespace": "custom"}),
47
+ ]
48
+ await client.close()
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_forget_rejects_empty_namespace_without_deleting_default():
53
+ client = CaptureMemWal.create(
54
+ key="11" * 32,
55
+ account_id="0x" + "ab" * 32,
56
+ server_url="https://relayer.example",
57
+ namespace="important",
58
+ )
59
+ with pytest.raises(ValueError, match="namespace cannot be empty"):
60
+ await client.forget("")
61
+ with pytest.raises(ValueError, match="namespace cannot be empty"):
62
+ await client.forget_namespace("")
63
+ assert client.captured == []
64
+ await client.close()
65
+
66
+
67
+ def test_sync_client_has_forget():
68
+ assert hasattr(MemWal, "forget")
69
+ assert hasattr(MemWalSync, "forget")
70
+ assert hasattr(MemWal, "forget_namespace")
71
+ assert hasattr(MemWalSync, "forget_namespace")
72
+ sync_client = MemWalSync(
73
+ CaptureMemWal.create(
74
+ key="11" * 32,
75
+ account_id="0x" + "ab" * 32,
76
+ server_url="https://relayer.example",
77
+ namespace="important",
78
+ )
79
+ )
80
+ with pytest.raises(ValueError, match="namespace cannot be empty"):
81
+ sync_client.forget("")
82
+ with pytest.raises(ValueError, match="namespace cannot be empty"):
83
+ sync_client.forget_namespace("")
84
+ assert sync_client._inner.captured == []
85
+ assert sync_client.forget_namespace("archive") == ForgetResult(
86
+ deleted=2, namespace="archive", owner="0xowner"
87
+ )
88
+ assert sync_client._inner.captured == [
89
+ ("POST", "/api/forget", {"namespace": "archive"})
90
+ ]
91
+ sync_client.close()
@@ -2,7 +2,7 @@
2
2
  """
3
3
  Integration tests for the Walrus Memory Python SDK against a live server.
4
4
 
5
- Targets MEMWAL_SERVER_URL (default: https://relayer-staging.memory.walrus.xyz).
5
+ Targets MEMWAL_SERVER_URL (default: https://relayer.dev.memwal.ai).
6
6
 
7
7
  No-auth tests (always run, no env vars needed):
8
8
  - /health endpoint
@@ -31,7 +31,7 @@ Usage:
31
31
  # Run against staging using env vars
32
32
  export MEMWAL_PRIVATE_KEY="<your-ed25519-delegate-private-key-hex>"
33
33
  export MEMWAL_ACCOUNT_ID="0x-your-walrus-memory-account-id"
34
- export MEMWAL_SERVER_URL="https://relayer-staging.memory.walrus.xyz"
34
+ export MEMWAL_SERVER_URL="https://relayer.dev.memwal.ai"
35
35
  python -m pytest tests/test_integration.py -v
36
36
  """
37
37
 
@@ -54,7 +54,7 @@ pytestmark = pytest.mark.integration
54
54
 
55
55
  # ── Config ───────────────────────────────────────────────────────────────────
56
56
 
57
- SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer-staging.memory.walrus.xyz")
57
+ SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer.dev.memwal.ai")
58
58
  PRIVATE_KEY_HEX = os.environ.get("MEMWAL_PRIVATE_KEY", "")
59
59
  ACCOUNT_ID = os.environ.get("MEMWAL_ACCOUNT_ID", "")
60
60
 
@@ -98,6 +98,9 @@ def _async_client(namespace: str = _E2E_NAMESPACE) -> MemWal:
98
98
  # ── Helpers ───────────────────────────────────────────────────────────────────
99
99
 
100
100
 
101
+ _HTTP_CLIENT = httpx.Client(timeout=30, transport=httpx.HTTPTransport(retries=3))
102
+
103
+
101
104
  def _raw_signed_request(
102
105
  method: str,
103
106
  path: str,
@@ -125,20 +128,19 @@ def _raw_signed_request(
125
128
  signature_hex = signed.signature.hex()
126
129
  pub_key_hex = pub_key_override or signing_key.verify_key.encode().hex()
127
130
 
128
- with httpx.Client(timeout=30) as client:
129
- return client.request(
130
- method,
131
- f"{base_url}{path}",
132
- content=body_bytes,
133
- headers={
134
- "Content-Type": "application/json",
135
- "x-public-key": pub_key_hex,
136
- "x-signature": signature_hex,
137
- "x-timestamp": timestamp,
138
- "x-nonce": nonce,
139
- "x-account-id": ACCOUNT_ID or "0x0",
140
- },
141
- )
131
+ return _HTTP_CLIENT.request(
132
+ method,
133
+ f"{base_url}{path}",
134
+ content=body_bytes,
135
+ headers={
136
+ "Content-Type": "application/json",
137
+ "x-public-key": pub_key_hex,
138
+ "x-signature": signature_hex,
139
+ "x-timestamp": timestamp,
140
+ "x-nonce": nonce,
141
+ "x-account-id": ACCOUNT_ID or "0x0",
142
+ },
143
+ )
142
144
 
143
145
 
144
146
  # ── No-auth tests (always run) ────────────────────────────────────────────────
@@ -168,8 +170,7 @@ class TestAuthRejection:
168
170
 
169
171
  def test_unsigned_request_rejected(self) -> None:
170
172
  """Request with no auth headers → 401."""
171
- with httpx.Client(timeout=30) as client:
172
- resp = client.post(f"{SERVER_URL}/api/remember", json=self._body)
173
+ resp = _HTTP_CLIENT.post(f"{SERVER_URL}/api/remember", json=self._body)
173
174
  assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
174
175
 
175
176
  def test_wrong_signature_rejected(self) -> None:
@@ -0,0 +1,310 @@
1
+ """Empty or non-JSON 2xx responses raise ``MemWalJsonBodyError`` (#1219).
2
+
3
+ Mirrors ``packages/sdk/test/json-body.test.mjs`` (#1201 / #1207): a relayer or
4
+ proxy in front of one that answers 2xx with an empty or non-JSON body must
5
+ raise a :class:`MemWalError` naming the endpoint and HTTP status rather than
6
+ leaking Python's raw :class:`json.JSONDecodeError`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ import httpx
14
+ import nacl.signing
15
+ import pytest
16
+ import respx
17
+
18
+ from memwal import (
19
+ MemWal,
20
+ MemWalCompatibilityError,
21
+ MemWalError,
22
+ MemWalJsonBodyError,
23
+ MemWalRememberJobNotFound,
24
+ MemWalSync,
25
+ )
26
+ from memwal.types import RememberManualOptions
27
+ from memwal.utils import bytes_to_hex
28
+
29
+ _SERVER = "https://relayer.example"
30
+ _KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x11" * 32)))
31
+ _VERSION_JSON = {
32
+ "apiVersion": "1.0.0",
33
+ "relayerVersion": "1.0.0",
34
+ "minSupportedSdk": {"python": "0.1.0"},
35
+ }
36
+
37
+
38
+ def _client() -> MemWal:
39
+ return MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
40
+
41
+
42
+ def _mock_version() -> None:
43
+ respx.get(f"{_SERVER}/version").mock(
44
+ return_value=httpx.Response(200, json=_VERSION_JSON)
45
+ )
46
+
47
+
48
+ def _mock_seal_session_prereqs() -> None:
49
+ _mock_version()
50
+ respx.get(f"{_SERVER}/config").mock(
51
+ return_value=httpx.Response(
52
+ 200,
53
+ json={
54
+ "packageId": "0xpkg",
55
+ "network": "testnet",
56
+ "suiRpcUrl": "https://fullnode.testnet.sui.io:443",
57
+ },
58
+ )
59
+ )
60
+ respx.post("https://graphql.testnet.sui.io/graphql").mock(
61
+ return_value=httpx.Response(
62
+ 200,
63
+ json={"data": {"object": {"version": 1}}},
64
+ )
65
+ )
66
+
67
+
68
+ @respx.mock
69
+ async def test_health_rejects_empty_200_with_memwal_error() -> None:
70
+ respx.get(f"{_SERVER}/health").mock(return_value=httpx.Response(200, content=b" "))
71
+ with pytest.raises(MemWalJsonBodyError) as exc:
72
+ await _client().health()
73
+ assert isinstance(exc.value, MemWalError)
74
+ assert not isinstance(exc.value, json.JSONDecodeError)
75
+ assert exc.value.status == 200
76
+ assert exc.value.endpoint == "GET /health"
77
+ assert "GET /health" in str(exc.value)
78
+ assert "empty body; expected JSON" in str(exc.value)
79
+
80
+
81
+ @respx.mock
82
+ async def test_health_rejects_non_json_200_and_redacts_internal_urls() -> None:
83
+ respx.get(f"{_SERVER}/health").mock(
84
+ return_value=httpx.Response(
85
+ 200,
86
+ text="<html>upstream http://localhost:8080/health failed</html>",
87
+ headers={"content-type": "text/html"},
88
+ )
89
+ )
90
+ with pytest.raises(MemWalJsonBodyError) as exc:
91
+ await _client().health()
92
+ assert isinstance(exc.value, MemWalError)
93
+ assert not isinstance(exc.value, json.JSONDecodeError)
94
+ assert exc.value.status == 200
95
+ assert exc.value.endpoint == "GET /health"
96
+ assert "response was not JSON" in str(exc.value)
97
+ assert "localhost" not in str(exc.value)
98
+ assert "[internal]" in str(exc.value)
99
+
100
+
101
+ @respx.mock
102
+ async def test_compatibility_rejects_empty_and_non_json_version_200() -> None:
103
+ route = respx.get(f"{_SERVER}/version")
104
+ for content, expected_phrase in (
105
+ (b"", "empty body; expected JSON"),
106
+ (b"nope", "response was not JSON"),
107
+ ):
108
+ route.mock(return_value=httpx.Response(200, content=content))
109
+ with pytest.raises(MemWalJsonBodyError) as exc:
110
+ await _client().compatibility()
111
+ assert exc.value.status == 200
112
+ assert exc.value.endpoint == "GET /version"
113
+ assert expected_phrase in str(exc.value)
114
+
115
+
116
+ @respx.mock
117
+ async def test_compatibility_rejects_non_json_health_fallback_200() -> None:
118
+ respx.get(f"{_SERVER}/version").mock(return_value=httpx.Response(404))
119
+ respx.get(f"{_SERVER}/health").mock(
120
+ return_value=httpx.Response(200, text="bad gateway html")
121
+ )
122
+ with pytest.raises(MemWalJsonBodyError) as exc:
123
+ await _client().compatibility()
124
+ assert exc.value.status == 200
125
+ assert exc.value.endpoint == "GET /health"
126
+ assert "response was not JSON" in str(exc.value)
127
+
128
+
129
+ @respx.mock
130
+ async def test_compatibility_rejects_non_object_json_200() -> None:
131
+ respx.get(f"{_SERVER}/version").mock(return_value=httpx.Response(200, content=b"null"))
132
+ with pytest.raises(MemWalCompatibilityError):
133
+ await _client().compatibility()
134
+
135
+
136
+ @respx.mock
137
+ async def test_config_and_package_version_reject_non_json_200() -> None:
138
+ _mock_version()
139
+ respx.get(f"{_SERVER}/config").mock(return_value=httpx.Response(200, content=b""))
140
+ with pytest.raises(MemWalJsonBodyError) as exc:
141
+ await _client().recall("coffee")
142
+ assert exc.value.status == 200
143
+ assert exc.value.endpoint == "GET /config"
144
+ assert "empty body; expected JSON" in str(exc.value)
145
+
146
+ respx.get(f"{_SERVER}/config").mock(
147
+ return_value=httpx.Response(
148
+ 200,
149
+ json={
150
+ "packageId": "0xpkg",
151
+ "network": "testnet",
152
+ "suiRpcUrl": "https://fullnode.testnet.sui.io:443",
153
+ },
154
+ )
155
+ )
156
+ respx.post("https://graphql.testnet.sui.io/graphql").mock(
157
+ return_value=httpx.Response(200, text="not-json")
158
+ )
159
+ with pytest.raises(MemWalJsonBodyError) as gql_exc:
160
+ await _client().recall("coffee")
161
+ assert gql_exc.value.status == 200
162
+ assert "graphql.testnet.sui.io" in gql_exc.value.endpoint
163
+ assert "response was not JSON" in str(gql_exc.value)
164
+
165
+
166
+ @respx.mock
167
+ async def test_remember_rejects_empty_202_without_polling() -> None:
168
+ _mock_seal_session_prereqs()
169
+ respx.post(f"{_SERVER}/api/remember").mock(
170
+ return_value=httpx.Response(202, content=b"")
171
+ )
172
+ poll_route = respx.get(url__regex=rf"^{_SERVER}/api/remember/.+").mock(
173
+ return_value=httpx.Response(200, json={"job_id": "j1", "status": "done"})
174
+ )
175
+
176
+ client = _client()
177
+ with pytest.raises(MemWalJsonBodyError) as exc:
178
+ await client.remember_and_wait("hello", timeout_ms=5_000)
179
+ assert exc.value.status == 202
180
+ assert exc.value.endpoint == "POST /api/remember"
181
+ assert "empty body; expected JSON" in str(exc.value)
182
+ assert poll_route.call_count == 0
183
+
184
+
185
+ @respx.mock
186
+ async def test_signed_requests_reject_empty_non_json_and_non_object_200() -> None:
187
+ _mock_seal_session_prereqs()
188
+ respx.post(f"{_SERVER}/api/stats").mock(
189
+ return_value=httpx.Response(200, json={"owner": "0xowner"})
190
+ )
191
+ ns_route = respx.get(f"{_SERVER}/v1/owners/0xowner/namespaces")
192
+ for body in (b"", b"<html>error</html>", b"null", b"[]"):
193
+ ns_route.mock(return_value=httpx.Response(200, content=body))
194
+ with pytest.raises(MemWalJsonBodyError) as exc:
195
+ await _client().list_namespaces()
196
+ assert exc.value.status == 200
197
+ assert exc.value.endpoint == "GET /v1/owners/0xowner/namespaces"
198
+
199
+ restore_route = respx.post(f"{_SERVER}/api/restore")
200
+ restore_route.mock(return_value=httpx.Response(200, content=b""))
201
+ with pytest.raises(MemWalJsonBodyError) as restore_exc:
202
+ await _client().restore("default")
203
+ assert restore_exc.value.status == 200
204
+ assert restore_exc.value.endpoint == "POST /api/restore"
205
+
206
+ manual_route = respx.post(f"{_SERVER}/api/remember/manual")
207
+ manual_route.mock(return_value=httpx.Response(200, text="nope"))
208
+ with pytest.raises(MemWalJsonBodyError) as manual_exc:
209
+ await _client().remember_manual(
210
+ RememberManualOptions(encrypted_data="ZGF0YQ==", vector=[0.1])
211
+ )
212
+ assert manual_exc.value.status == 200
213
+ assert manual_exc.value.endpoint == "POST /api/remember/manual"
214
+
215
+
216
+ @respx.mock
217
+ async def test_remember_status_distinguishes_200_non_json_from_404_non_json() -> None:
218
+ _mock_seal_session_prereqs()
219
+ route = respx.get(f"{_SERVER}/api/remember/job-1")
220
+
221
+ # 200 with empty or non-JSON body raises MemWalJsonBodyError
222
+ for content in (b"", b"<html>bad</html>"):
223
+ route.mock(return_value=httpx.Response(200, content=content))
224
+ with pytest.raises(MemWalJsonBodyError) as exc:
225
+ await _client().get_remember_status("job-1")
226
+ assert exc.value.status == 200
227
+ assert exc.value.endpoint == "GET /api/remember/job-1"
228
+
229
+ with pytest.raises(MemWalJsonBodyError) as wait_exc:
230
+ await _client().wait_for_remember_job(
231
+ "job-1", poll_interval_ms=0, timeout_ms=5_000
232
+ )
233
+ assert wait_exc.value.status == 200
234
+
235
+ # 404 with empty or non-JSON body remains not_found
236
+ route.mock(return_value=httpx.Response(404, content=b""))
237
+ status = await _client().get_remember_status("job-1")
238
+ assert status.status == "not_found"
239
+ with pytest.raises(MemWalRememberJobNotFound):
240
+ await _client().wait_for_remember_job(
241
+ "job-1", poll_interval_ms=0, timeout_ms=5_000
242
+ )
243
+
244
+
245
+ @respx.mock
246
+ def test_sync_client_surfaces_memwal_json_body_error() -> None:
247
+ respx.get(f"{_SERVER}/health").mock(return_value=httpx.Response(200, content=b""))
248
+ sync_client = MemWalSync.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
249
+ with pytest.raises(MemWalJsonBodyError) as exc:
250
+ sync_client.health()
251
+ assert exc.value.status == 200
252
+ assert exc.value.endpoint == "GET /health"
253
+
254
+
255
+ @respx.mock
256
+ async def test_health_rejects_malformed_utf8_inside_json_string() -> None:
257
+ route = respx.get(f"{_SERVER}/health")
258
+ for raw in (
259
+ b'{"status":"ok","version":"\xff"}',
260
+ b"\x80\x81\x82",
261
+ ):
262
+ route.mock(
263
+ return_value=httpx.Response(
264
+ 200,
265
+ content=raw,
266
+ headers={"content-type": "application/json"},
267
+ )
268
+ )
269
+ with pytest.raises(MemWalJsonBodyError) as exc:
270
+ await _client().health()
271
+ assert isinstance(exc.value, MemWalError)
272
+ assert not isinstance(exc.value, UnicodeDecodeError)
273
+ assert exc.value.status == 200
274
+ assert exc.value.endpoint == "GET /health"
275
+ assert "response was not JSON" in str(exc.value)
276
+
277
+
278
+ @respx.mock
279
+ async def test_health_accepts_utf8_bom_and_utf16_json_bodies() -> None:
280
+ route = respx.get(f"{_SERVER}/health")
281
+ payload = '{"status":"ok","version":"0.2.2"}'
282
+ for encoded in (
283
+ b"\xef\xbb\xbf" + payload.encode("utf-8"),
284
+ payload.encode("utf-16"),
285
+ payload.encode("utf-16-le"),
286
+ payload.encode("utf-16-be"),
287
+ ):
288
+ route.mock(
289
+ return_value=httpx.Response(
290
+ 200,
291
+ content=encoded,
292
+ headers={"content-type": "application/json"},
293
+ )
294
+ )
295
+ health = await _client().health()
296
+ assert health.status == "ok"
297
+ assert health.version == "0.2.2"
298
+
299
+ # BOM-only or UTF-16 whitespace-only payloads still surface as empty JSON body errors
300
+ for empty_encoded in (
301
+ b"\xef\xbb\xbf",
302
+ b"\xef\xbb\xbf ",
303
+ " ".encode("utf-16"),
304
+ ):
305
+ route.mock(return_value=httpx.Response(200, content=empty_encoded))
306
+ with pytest.raises(MemWalJsonBodyError) as exc:
307
+ await _client().health()
308
+ assert exc.value.status == 200
309
+ assert exc.value.endpoint == "GET /health"
310
+ assert "empty body; expected JSON" in str(exc.value)
@@ -121,7 +121,18 @@ async def test_mock_supports_jobs_bulk_analyze_forget_and_clear():
121
121
 
122
122
  assert mock.forget("mock-blob-000001") is True
123
123
  assert mock.forget("missing") is False
124
- assert mock.clear("one") == 1
124
+ assert (await mock.recall("single fact")).total == 0
125
+ assert (await mock.recall("bulk", namespace="one")).total == 1
126
+ assert (await mock.recall("bulk", namespace="two")).total == 1
127
+ assert (await mock.recall("durable analyzed fact", namespace="analysis")).total == 1
128
+ with pytest.raises(ValueError, match="namespace cannot be empty"):
129
+ await mock.forget_namespace("")
130
+ forgotten = await mock.forget_namespace("one")
131
+ assert forgotten.namespace == "one"
132
+ assert forgotten.deleted == 1
133
+ assert (await mock.recall("bulk", namespace="two")).total == 1
134
+ assert (await mock.recall("durable analyzed fact", namespace="analysis")).total == 1
135
+ assert mock.clear("one") == 0
125
136
  assert (await mock.recall("bulk", namespace="one")).total == 0
126
137
 
127
138
 
@@ -171,6 +182,17 @@ def test_sync_mock_wraps_core_flows_and_accepts_production_options():
171
182
  assert analyzed_wait.succeeded == 1
172
183
  assert recalled.results[0].text == "sync memory"
173
184
 
185
+ assert recalled.total == 5
186
+ assert mock.forget(stored.blob_id) is True
187
+ assert mock.forget("missing-blob") is False
188
+ assert mock.recall("sync").total == 4
189
+ with pytest.raises(ValueError, match="namespace cannot be empty"):
190
+ mock.forget_namespace("")
191
+ forgotten = mock.forget_namespace("sync")
192
+ assert forgotten.namespace == "sync"
193
+ assert forgotten.deleted == 4
194
+ assert mock.recall("sync").total == 0
195
+
174
196
 
175
197
  def test_sync_mock_polling_and_analyze_signatures_match_production():
176
198
  methods = (
@@ -182,6 +204,7 @@ def test_sync_mock_polling_and_analyze_signatures_match_production():
182
204
  "remember_bulk_and_wait",
183
205
  "analyze",
184
206
  "analyze_and_wait",
207
+ "forget_namespace",
185
208
  )
186
209
 
187
210
  def parameter_contract(method):
File without changes
File without changes