memwal 0.2.0.dev0__tar.gz → 0.2.0.dev1__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 (37) hide show
  1. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/CHANGELOG.md +5 -0
  2. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/PKG-INFO +1 -1
  3. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/__init__.py +1 -1
  4. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/client.py +102 -43
  5. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/middleware.py +157 -23
  6. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/mock.py +5 -3
  7. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/pyproject.toml +1 -1
  8. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_middleware.py +267 -1
  9. memwal-0.2.0.dev1/tests/test_nonfinite_validation.py +238 -0
  10. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/.gitignore +0 -0
  11. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/README.md +0 -0
  12. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/.env.example +0 -0
  13. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/.gitignore +0 -0
  14. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/async_remember_demo.py +0 -0
  15. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/interactive_demo.py +0 -0
  16. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/verify_credentials.py +0 -0
  17. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/compatibility.py +0 -0
  18. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/types.py +0 -0
  19. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/utils.py +0 -0
  20. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  21. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/run_tests.py +0 -0
  22. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/__init__.py +0 -0
  23. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_auth_rejected_message.py +0 -0
  24. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_blank_text.py +0 -0
  25. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_client.py +0 -0
  26. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_empty_namespace.py +0 -0
  27. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_env_presets.py +0 -0
  28. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_integration.py +0 -0
  29. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_list_namespaces.py +0 -0
  30. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_mock.py +0 -0
  31. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_normalize_server_url.py +0 -0
  32. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_polling_delay.py +0 -0
  33. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_recall_weights.py +0 -0
  34. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_remember_derived_key.py +0 -0
  35. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_retry_after_http_date.py +0 -0
  36. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_signing.py +0 -0
  37. {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_transport_retry.py +0 -0
@@ -12,6 +12,11 @@ Unreleased.
12
12
 
13
13
  - Require certifi 2026.10.19 or newer to remove trust in the compromised emSign certificate chain, and AnyIO 4.14.2 or newer for its TLS hostname fix. Update pip separately to 26.2.2 or newer; the SDK cannot replace pip's vendored trust store.
14
14
 
15
+ ### Fixed
16
+
17
+ - `with_memwal_*` gains `save_mode` and confirmable saves: `memwal_wait_for_saves` (async and sync) polls every job the middleware enqueued, and restores the job ids if that poll raises so a retry can still confirm them. (WALM-307, #410, #411, #412)
18
+ - `wait_for_remember_jobs` chunks its status reads to the relayer's 20-id `MAX_BULK_ITEMS` cap. Above it the relayer answers a non-transient 400, so a wait over 21+ jobs raised on every attempt and could never confirm — reachable from `save_mode="remember"` after 21 turns, or from two fat `analyze` extracts. (WALM-307)
19
+
15
20
  ## 0.1.12
16
21
 
17
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.2.0.dev0
3
+ Version: 0.2.0.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
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.2.0.dev0"
131
+ __version__ = "0.2.0.dev1"
@@ -98,6 +98,10 @@ PUBLIC_SUI_GRAPHQL_NETWORKS = ("mainnet", "testnet", "devnet")
98
98
  # Sui read endpoint is unresponsive.
99
99
  PACKAGE_VERSION_CHECK_TIMEOUT_S = 10.0
100
100
  SEAL_SESSION_SAFETY_MARGIN_MS = 30_000
101
+
102
+ # The relayer's `MAX_BULK_ITEMS` (services/server/src/types.rs). A bulk read
103
+ # above it is rejected with a non-transient 400, so callers must chunk.
104
+ MAX_BULK_STATUS_IDS = 20
101
105
  AUTH_REJECTED_MESSAGE = (
102
106
  "401 from relayer: typically wrong private key, key not registered on this "
103
107
  "account, account ID mismatch (a wallet address is not a MemWal account ID), "
@@ -410,6 +414,29 @@ def _occurred_at_to_wire(
410
414
  )
411
415
 
412
416
 
417
+
418
+ def _check_limit(limit: Any) -> None:
419
+ """Reject a limit that is not a positive integer before it is signed (#1218)."""
420
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
421
+ raise ValueError(f"limit must be a positive integer, got {limit!r}")
422
+
423
+
424
+ def _check_vector(vector: Any) -> None:
425
+ """Reject a vector that is not a non-empty list of finite numbers (#1218).
426
+
427
+ json.dumps would otherwise emit bare NaN/Infinity tokens, which are not
428
+ JSON, and the relayer would answer with an opaque parse error.
429
+ """
430
+ if (
431
+ not isinstance(vector, (list, tuple))
432
+ or len(vector) == 0
433
+ or not all(
434
+ isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
435
+ for v in vector
436
+ )
437
+ ):
438
+ raise ValueError("vector must be a non-empty list of finite numbers")
439
+
413
440
  class MemWal:
414
441
  """Async-native Walrus Memory client.
415
442
 
@@ -907,60 +934,81 @@ class MemWal:
907
934
  attempt += 1
908
935
  retry_after_ms = 0
909
936
 
910
- try:
911
- poll_timeout = self._poll_deadline_s(deadline_ms)
937
+ # The relayer rejects a bulk-status read of more than
938
+ # MAX_BULK_STATUS_IDS ids with a 400, which is NOT transient: an
939
+ # unchunked poll of 21+ jobs raises out of the whole wait and
940
+ # every retry hits the same 400. Chunk here so `memwal_wait_for_saves`
941
+ # and `remember_bulk_and_wait` are both fixed.
942
+ terminal_ids: Set[str] = set()
943
+ refused_ids: List[str] = []
944
+ for start in range(0, len(pending), MAX_BULK_STATUS_IDS):
945
+ chunk = pending[start : start + MAX_BULK_STATUS_IDS]
912
946
  try:
913
- batch = await self.get_remember_bulk_status(pending, timeout=poll_timeout)
914
- except TypeError as err:
915
- if "unexpected keyword argument" in str(err) and "timeout" in str(err):
916
- batch = await self.get_remember_bulk_status(pending)
917
- else:
918
- raise
919
- except _HttpStatusError as err:
920
- if _is_transient_polling_status(err.status):
921
- last_refusal = err.status
922
- if err.status == 429:
923
- saw_rate_limit = True
924
- retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
947
+ poll_timeout = self._poll_deadline_s(deadline_ms)
948
+ try:
949
+ batch = await self.get_remember_bulk_status(chunk, timeout=poll_timeout)
950
+ except TypeError as err:
951
+ if "unexpected keyword argument" in str(err) and "timeout" in str(err):
952
+ batch = await self.get_remember_bulk_status(chunk)
953
+ else:
954
+ raise
955
+ except _HttpStatusError as err:
956
+ if _is_transient_polling_status(err.status):
957
+ last_refusal = err.status
958
+ if err.status == 429:
959
+ saw_rate_limit = True
960
+ retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
961
+ # Refused, not answered: keep this chunk pending.
962
+ refused_ids.extend(chunk)
963
+ continue
964
+ raise
965
+ except httpx.TransportError:
966
+ # Network drops retry as status 0; continuing next loop iteration
967
+ # increments `attempt` so exponential backoff delay continues to grow.
968
+ last_refusal = 0
969
+ refused_ids.extend(chunk)
925
970
  continue
926
- raise
927
- except httpx.TransportError:
928
- # Network drops retry as status 0; continuing next loop iteration
929
- # increments `attempt` so exponential backoff delay continues to grow.
930
- last_refusal = 0
931
- continue
932
- answered = True
971
+ answered = True
972
+ chunk_pending = set(chunk)
973
+ terminal_ids |= {
974
+ item.job_id
975
+ for item in batch.results
976
+ if item.job_id in chunk_pending and settle(item)
977
+ }
933
978
 
934
- terminal_ids: Set[str] = {
935
- item.job_id
936
- for item in batch.results
937
- if item.job_id in pending and settle(item)
938
- }
979
+ if not answered and refused_ids:
980
+ # Nothing got through this tick; back off and retry.
981
+ continue
939
982
  pending = [jid for jid in pending if jid not in terminal_ids]
940
983
 
941
984
  if opts.timeout_ms > 0 and len(pending) == len(job_ids) and not last_seen:
942
985
  # Nothing settled and nothing was seen: the polls may all have been
943
986
  # refused. Confirm with one direct read before reporting anything.
944
- try:
945
- probed = await self.get_remember_bulk_status(list(job_ids))
946
- except _HttpStatusError as err:
947
- if err.status == 429:
948
- # Header first, else the body's retry_after_seconds, so a
949
- # proxy that strips Retry-After does not lose the hint.
950
- wait_ms = _retry_after_ms(err)
951
- retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
952
- raise MemWalRateLimited(job_ids, retry_after) from err
953
- # Any other probe failure is not evidence about the jobs.
954
- except httpx.TransportError:
955
- pass
956
- else:
987
+ settled: Set[str] = set()
988
+ all_ids = list(job_ids)
989
+ for start in range(0, len(all_ids), MAX_BULK_STATUS_IDS):
990
+ chunk = all_ids[start : start + MAX_BULK_STATUS_IDS]
991
+ try:
992
+ probed = await self.get_remember_bulk_status(chunk)
993
+ except _HttpStatusError as err:
994
+ if err.status == 429:
995
+ # Header first, else the body's retry_after_seconds, so a
996
+ # proxy that strips Retry-After does not lose the hint.
997
+ wait_ms = _retry_after_ms(err)
998
+ retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
999
+ raise MemWalRateLimited(job_ids, retry_after) from err
1000
+ # Any other probe failure is not evidence about the jobs.
1001
+ continue
1002
+ except httpx.TransportError:
1003
+ continue
957
1004
  answered = True
958
- settled = {
1005
+ pending_now = set(pending)
1006
+ settled |= {
959
1007
  item.job_id
960
1008
  for item in probed.results
961
- if item.job_id in pending and settle(item)
1009
+ if item.job_id in pending_now and settle(item)
962
1010
  }
963
- pending = [jid for jid in pending if jid not in settled]
1011
+ pending = [jid for jid in pending if jid not in settled]
964
1012
 
965
1013
  for job_id in pending:
966
1014
  seen = last_seen.get(job_id)
@@ -1057,6 +1105,7 @@ class MemWal:
1057
1105
  sort = params.sort
1058
1106
  else:
1059
1107
  query_text = query
1108
+ _check_limit(limit)
1060
1109
  body = {
1061
1110
  "query": query_text,
1062
1111
  "limit": limit,
@@ -1299,6 +1348,7 @@ class MemWal:
1299
1348
  downloads in parallel, then 3 SEAL decrypts in parallel, then
1300
1349
  embeddings. Expect seconds-per-blob on cold caches.
1301
1350
  """
1351
+ _check_limit(limit)
1302
1352
  data = await self._signed_request("POST", "/api/restore", {
1303
1353
  "namespace": namespace,
1304
1354
  "limit": limit,
@@ -1354,6 +1404,10 @@ class MemWal:
1354
1404
  Returns:
1355
1405
  :class:`NamespacesResult`.
1356
1406
  """
1407
+ # Validate before resolving the owner: on a fresh client that lookup is
1408
+ # a signed POST /api/stats, so a bad limit must not reach it (#1218).
1409
+ if limit is not None:
1410
+ _check_limit(limit)
1357
1411
  owner = await self._resolve_owner()
1358
1412
 
1359
1413
  params: Dict[str, str] = {}
@@ -1428,6 +1482,7 @@ class MemWal:
1428
1482
  Returns:
1429
1483
  :class:`RememberManualResult` with id, blob_id, owner, namespace.
1430
1484
  """
1485
+ _check_vector(opts.vector)
1431
1486
  data = await self._signed_request(
1432
1487
  "POST",
1433
1488
  "/api/remember/manual",
@@ -1457,6 +1512,8 @@ class MemWal:
1457
1512
  Returns:
1458
1513
  :class:`RecallManualResult` with blob_id + distance pairs.
1459
1514
  """
1515
+ _check_vector(opts.vector)
1516
+ _check_limit(opts.limit)
1460
1517
  body: Dict[str, Any] = {
1461
1518
  "vector": opts.vector,
1462
1519
  "limit": opts.limit,
@@ -1751,7 +1808,9 @@ class MemWal:
1751
1808
 
1752
1809
  method_upper = method.upper()
1753
1810
  timestamp = str(int(time.time()))
1754
- body_str = "" if method_upper == "GET" else json.dumps(body, separators=(",", ":"))
1811
+ body_str = "" if method_upper == "GET" else json.dumps(
1812
+ body, separators=(",", ":"), allow_nan=False
1813
+ )
1755
1814
  body_hash = sha256_hex(body_str)
1756
1815
  # MED-1 / LOW-23: nonce + account_id are part of the canonical signed
1757
1816
  # message. Server rejects the request as "unsupported legacy SDK"
@@ -55,7 +55,7 @@ from typing import (
55
55
  )
56
56
 
57
57
  from .client import MemWal, _with_fresh_http_client
58
- from .types import RecallMemory
58
+ from .types import RecallMemory, RememberBulkOptions, RememberBulkResult
59
59
 
60
60
  if TYPE_CHECKING:
61
61
  from langchain_core.language_models.chat_models import BaseChatModel
@@ -178,6 +178,28 @@ def _format_memories(
178
178
  ])
179
179
 
180
180
 
181
+ SAVE_MODES = ("analyze", "remember")
182
+ """Valid ``save_mode`` values for the auto-save path.
183
+
184
+ ``"analyze"`` extracts spoken-fact-style statements via the server LLM and
185
+ stores one memory per fact — it is lossy by design, and non-sentence content
186
+ (code snippets, structured data) yields zero facts. ``"remember"`` stores the
187
+ user message verbatim as a single memory, which is what callers saving code or
188
+ other non-conversational content want.
189
+ """
190
+
191
+
192
+ def _validate_save_mode(save_mode: str) -> str:
193
+ if save_mode not in SAVE_MODES:
194
+ raise ValueError(
195
+ f"save_mode must be one of {SAVE_MODES}, got {save_mode!r}"
196
+ )
197
+ return save_mode
198
+
199
+
200
+ _MAX_TRACKED_JOB_IDS = 1000
201
+
202
+
181
203
  class _PendingSaves:
182
204
  """Tracks in-flight fire-and-forget auto-save work (asyncio Tasks and
183
205
  background Threads) so a caller can drain it deterministically via
@@ -197,6 +219,7 @@ class _PendingSaves:
197
219
  self._failures: List[tuple[str, str]] = []
198
220
  self._dropped = 0
199
221
  self._on_save_error = on_save_error
222
+ self._job_ids: List[str] = []
200
223
  self._lock = threading.Lock()
201
224
  self._callback_threads: set[int] = set()
202
225
 
@@ -250,6 +273,23 @@ class _PendingSaves:
250
273
  f"Walrus Memory auto-save failed ({total} call(s)): {detail}"
251
274
  )
252
275
 
276
+ def track_job_ids(self, job_ids: List[str]) -> None:
277
+ """Record the background remember jobs an auto-save enqueued, so a
278
+ caller can later confirm they finished and read their blob IDs."""
279
+ if not job_ids:
280
+ return
281
+ with self._lock:
282
+ self._job_ids.extend(job_ids)
283
+ if len(self._job_ids) > _MAX_TRACKED_JOB_IDS:
284
+ del self._job_ids[:-_MAX_TRACKED_JOB_IDS]
285
+
286
+ def drain_job_ids(self) -> List[str]:
287
+ """Return every job ID recorded so far and forget them, so a second
288
+ wait does not re-poll jobs that already settled."""
289
+ with self._lock:
290
+ job_ids, self._job_ids = self._job_ids, []
291
+ return job_ids
292
+
253
293
  def track_task(self, task: "asyncio.Task[Any]") -> None:
254
294
  self._tasks.add(task)
255
295
  task.add_done_callback(self._tasks.discard)
@@ -333,6 +373,46 @@ class _PendingSaves:
333
373
  thread.join()
334
374
 
335
375
 
376
+ async def _run_auto_save(
377
+ memwal: MemWal,
378
+ text: str,
379
+ namespace: str,
380
+ save_mode: str,
381
+ pending: _PendingSaves,
382
+ log: Callable[..., Any],
383
+ ) -> None:
384
+ """Persist `text` via the configured save mode and record its job IDs.
385
+
386
+ ``analyze()`` returns cleanly with zero facts for content it cannot read
387
+ as spoken facts (code, structured data), so a plain call there looks
388
+ identical to a successful save. Log in that case and point at
389
+ ``save_mode="remember"`` rather than dropping the write silently.
390
+ """
391
+ try:
392
+ if save_mode == "remember":
393
+ accepted = await memwal.remember(text, namespace)
394
+ if accepted is not None and getattr(accepted, "job_id", None):
395
+ pending.track_job_ids([accepted.job_id])
396
+ return
397
+
398
+ result = await memwal.analyze(text, namespace)
399
+ if result is None:
400
+ return
401
+ job_ids = list(getattr(result, "job_ids", None) or [])
402
+ facts = getattr(result, "facts", None)
403
+ pending.track_job_ids(job_ids)
404
+ if not facts and not job_ids:
405
+ log(
406
+ "[Walrus Memory] Auto-save extracted 0 facts from a "
407
+ f"{len(text)}-character message and stored nothing. analyze() "
408
+ "only extracts spoken-fact-style statements — pass "
409
+ 'save_mode="remember" to store the message verbatim instead.'
410
+ )
411
+ except Exception as e: # noqa: BLE001 -- auto-save must never break the LLM call
412
+ log(f"[Walrus Memory] Auto-save failed: {e}")
413
+ pending.note_error(e)
414
+
415
+
336
416
  def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) -> None:
337
417
  """Attach the underlying client and a way to drain pending auto-saves —
338
418
  without this, a short-lived process has no way to avoid silently
@@ -349,9 +429,44 @@ def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) ->
349
429
  (obj.memwal_flush) still finds it — OpenAI clients aren't Pydantic
350
430
  models and work the same way either way.
351
431
  """
432
+ async def memwal_wait_for_saves(
433
+ opts: Optional[RememberBulkOptions] = None,
434
+ ) -> RememberBulkResult:
435
+ """Drain pending auto-saves, then poll their remember jobs to a
436
+ terminal state and return per-job status + blob IDs."""
437
+ await pending.flush()
438
+ job_ids = pending.drain_job_ids()
439
+ try:
440
+ return await memwal.wait_for_remember_jobs(job_ids, opts)
441
+ except Exception:
442
+ pending.track_job_ids(job_ids)
443
+ raise
444
+
445
+ def memwal_wait_for_saves_sync(
446
+ opts: Optional[RememberBulkOptions] = None,
447
+ ) -> RememberBulkResult:
448
+ """Sync counterpart of ``memwal_wait_for_saves`` for callers on the
449
+ blocking entry points (``OpenAI``, ``llm.invoke``)."""
450
+ pending.flush_sync()
451
+ job_ids = pending.drain_job_ids()
452
+ try:
453
+ return _run_blocking(
454
+ lambda: _with_fresh_http_client(
455
+ memwal, memwal.wait_for_remember_jobs(job_ids, opts)
456
+ )
457
+ )
458
+ except Exception:
459
+ pending.track_job_ids(job_ids)
460
+ raise
461
+
352
462
  object.__setattr__(obj, "_memwal", memwal)
463
+ # Public alias: reaching wait_for_remember_jobs() and the rest of the
464
+ # low-level client shouldn't require touching a private attribute.
465
+ object.__setattr__(obj, "memwal", memwal)
353
466
  object.__setattr__(obj, "memwal_flush", pending.flush)
354
467
  object.__setattr__(obj, "memwal_flush_sync", pending.flush_sync)
468
+ object.__setattr__(obj, "memwal_wait_for_saves", memwal_wait_for_saves)
469
+ object.__setattr__(obj, "memwal_wait_for_saves_sync", memwal_wait_for_saves_sync)
355
470
 
356
471
 
357
472
  async def _warn_if_cancelled(coro: Any, label: str) -> None:
@@ -416,6 +531,7 @@ def with_memwal_langchain(
416
531
  namespace: str = "default",
417
532
  max_memories: int = 5,
418
533
  auto_save: bool = True,
534
+ save_mode: str = "analyze",
419
535
  min_relevance: float = 0.3,
420
536
  debug: bool = False,
421
537
  env: Optional[str] = None,
@@ -438,6 +554,11 @@ def with_memwal_langchain(
438
554
  namespace: Default namespace.
439
555
  max_memories: Max memories to inject per request.
440
556
  auto_save: Auto-save new facts from conversation.
557
+ save_mode: How auto_save persists the user message. ``"analyze"``
558
+ (default) extracts spoken-fact-style statements and stores one
559
+ memory per fact — non-sentence content such as code yields zero
560
+ facts and stores nothing. ``"remember"`` stores the message
561
+ verbatim as a single memory.
441
562
  min_relevance: Minimum similarity score (0-1) to include a memory.
442
563
  debug: Enable debug logging.
443
564
  env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
@@ -458,6 +579,8 @@ def with_memwal_langchain(
458
579
  "Install with: pip install memwal[langchain]"
459
580
  ) from e
460
581
 
582
+ _validate_save_mode(save_mode)
583
+
461
584
  memwal = MemWal.create(
462
585
  key=key,
463
586
  account_id=account_id,
@@ -552,16 +675,12 @@ def with_memwal_langchain(
552
675
  return messages
553
676
 
554
677
  async def _post_analyze(messages: List[BaseMessage]) -> None:
555
- """Analyze user message for new facts."""
678
+ """Persist the user message via the configured save mode."""
556
679
  if not auto_save or not _is_last_message_user(messages):
557
680
  return
558
681
  user_text = _find_last_user_message(messages)
559
682
  if user_text:
560
- try:
561
- await memwal.analyze(user_text, namespace)
562
- except Exception as e:
563
- log(f"[Walrus Memory] Auto-save failed: {e}")
564
- pending.note_error(e)
683
+ await _run_auto_save(memwal, user_text, namespace, save_mode, pending, log)
565
684
 
566
685
  async def patched_agenerate(
567
686
  messages: List[BaseMessage], *args: Any, **kwargs: Any
@@ -604,6 +723,7 @@ def with_memwal_openai(
604
723
  namespace: str = "default",
605
724
  max_memories: int = 5,
606
725
  auto_save: bool = True,
726
+ save_mode: str = "analyze",
607
727
  min_relevance: float = 0.3,
608
728
  debug: bool = False,
609
729
  env: Optional[str] = None,
@@ -628,6 +748,11 @@ def with_memwal_openai(
628
748
  namespace: Default namespace.
629
749
  max_memories: Max memories to inject per request.
630
750
  auto_save: Auto-save new facts from conversation.
751
+ save_mode: How auto_save persists the user message. ``"analyze"``
752
+ (default) extracts spoken-fact-style statements and stores one
753
+ memory per fact — non-sentence content such as code yields zero
754
+ facts and stores nothing. ``"remember"`` stores the message
755
+ verbatim as a single memory.
631
756
  min_relevance: Minimum similarity score (0-1) to include a memory.
632
757
  debug: Enable debug logging.
633
758
  env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
@@ -639,6 +764,8 @@ def with_memwal_openai(
639
764
  Returns:
640
765
  The same client, with ``chat.completions.create`` wrapped to use Walrus Memory.
641
766
  """
767
+ _validate_save_mode(save_mode)
768
+
642
769
  memwal = MemWal.create(
643
770
  key=key,
644
771
  account_id=account_id,
@@ -654,11 +781,13 @@ def with_memwal_openai(
654
781
 
655
782
  if is_async:
656
783
  _wrap_async_openai(
657
- client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
784
+ client, memwal, namespace, max_memories, auto_save, save_mode,
785
+ min_relevance, log, pending,
658
786
  )
659
787
  else:
660
788
  _wrap_sync_openai(
661
- client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
789
+ client, memwal, namespace, max_memories, auto_save, save_mode,
790
+ min_relevance, log, pending,
662
791
  )
663
792
 
664
793
  _expose_memwal_controls(client, memwal, pending)
@@ -672,6 +801,7 @@ def _wrap_async_openai(
672
801
  namespace: str,
673
802
  max_memories: int,
674
803
  auto_save: bool,
804
+ save_mode: str,
675
805
  min_relevance: float,
676
806
  log: Callable[..., Any],
677
807
  pending: _PendingSaves,
@@ -725,18 +855,14 @@ def _wrap_async_openai(
725
855
 
726
856
  result = await original_create(*args, **kwargs)
727
857
 
728
- # Fire-and-forget analyze
858
+ # Fire-and-forget auto-save
729
859
  if auto_save and _is_last_message_user(messages):
730
860
  user_text = _find_last_user_message(messages)
731
861
  if user_text:
732
- async def _analyze() -> None:
733
- try:
734
- await memwal.analyze(user_text, namespace)
735
- except Exception as e:
736
- log(f"[Walrus Memory] Auto-save failed: {e}")
737
- pending.note_error(e)
738
-
739
- _fire_and_forget(_analyze(), pending)
862
+ _fire_and_forget(
863
+ _run_auto_save(memwal, user_text, namespace, save_mode, pending, log),
864
+ pending,
865
+ )
740
866
 
741
867
  return result
742
868
 
@@ -749,6 +875,7 @@ def _wrap_sync_openai(
749
875
  namespace: str,
750
876
  max_memories: int,
751
877
  auto_save: bool,
878
+ save_mode: str,
752
879
  min_relevance: float,
753
880
  log: Callable[..., Any],
754
881
  pending: _PendingSaves,
@@ -807,18 +934,25 @@ def _wrap_sync_openai(
807
934
 
808
935
  result = original_create(*args, **kwargs)
809
936
 
810
- # Fire-and-forget analyze
937
+ # Fire-and-forget auto-save
811
938
  if auto_save and _is_last_message_user(messages):
812
939
  user_text = _find_last_user_message(messages)
813
940
  if user_text:
814
- def _analyze() -> None:
941
+ def _save() -> None:
942
+ # _run_auto_save swallows request failures itself; this guards
943
+ # the loop/http-client plumbing around it so a failure there
944
+ # can't take down the background thread.
815
945
  try:
816
- _run_memwal(lambda: memwal.analyze(user_text, namespace))
817
- except Exception as e:
946
+ _run_memwal(
947
+ lambda: _run_auto_save(
948
+ memwal, user_text, namespace, save_mode, pending, log
949
+ )
950
+ )
951
+ except Exception as e: # noqa: BLE001
818
952
  log(f"[Walrus Memory] Auto-save failed: {e}")
819
953
  pending.note_error(e)
820
954
 
821
- pending.spawn_thread(_analyze)
955
+ pending.spawn_thread(_save)
822
956
 
823
957
  return result
824
958
 
@@ -12,6 +12,7 @@ from dataclasses import dataclass
12
12
  from datetime import datetime, timedelta, timezone
13
13
  from typing import Any, Dict, List, Optional, Sequence, Union
14
14
 
15
+ from .client import _check_limit
15
16
  from .types import (
16
17
  AnalyzedFact,
17
18
  AnalyzeResult,
@@ -279,8 +280,7 @@ class MemWalMock:
279
280
  sort = None
280
281
  query_text = query
281
282
  _validate_text(query_text, "query")
282
- if not isinstance(limit, int) or limit < 0:
283
- raise ValueError("limit must be a non-negative integer")
283
+ _check_limit(limit)
284
284
  if sort not in (None, "relevance", "recent"):
285
285
  raise ValueError("sort must be 'relevance' or 'recent'")
286
286
  resolved_namespace = self._namespace if namespace is None else namespace
@@ -383,7 +383,7 @@ class MemWalMock:
383
383
  )
384
384
 
385
385
  async def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
386
- del limit
386
+ _check_limit(limit)
387
387
  return RestoreResult(
388
388
  restored=0,
389
389
  skipped=0,
@@ -399,6 +399,8 @@ class MemWalMock:
399
399
  cursor: Optional[str] = None,
400
400
  limit: Optional[int] = None,
401
401
  ) -> NamespacesResult:
402
+ if limit is not None:
403
+ _check_limit(limit)
402
404
  grouped: Dict[str, List[_Memory]] = {}
403
405
  for memory in self._memories:
404
406
  grouped.setdefault(memory.namespace, []).append(memory)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.2.0.dev0"
7
+ version = "0.2.0.dev1"
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,14 +26,16 @@ import respx
26
26
 
27
27
  from memwal.client import MemWal
28
28
  from memwal.middleware import (
29
+ _MAX_TRACKED_JOB_IDS,
29
30
  _find_last_user_message,
30
31
  _format_memories,
31
32
  _inject_openai_memory,
32
33
  _PendingSaves,
34
+ _run_auto_save,
33
35
  with_memwal_langchain,
34
36
  with_memwal_openai,
35
37
  )
36
- from memwal.types import RecallMemory
38
+ from memwal.types import RecallMemory, RememberBulkOptions
37
39
  from memwal.utils import bytes_to_hex
38
40
 
39
41
  # ============================================================
@@ -131,6 +133,27 @@ def _mock_analyze() -> httpx.Response:
131
133
  )
132
134
 
133
135
 
136
+ _REMEMBER_URL = f"{_SERVER}/api/remember"
137
+ _BULK_STATUS_URL = f"{_SERVER}/api/remember/bulk/status"
138
+
139
+
140
+ def _mock_analyze_with_facts(*job_ids: str) -> httpx.Response:
141
+ return httpx.Response(
142
+ 202,
143
+ json={
144
+ "facts": [{"text": f"fact {i}", "id": job_id} for i, job_id in enumerate(job_ids)],
145
+ "job_ids": list(job_ids),
146
+ "fact_count": len(job_ids),
147
+ "status": "pending",
148
+ "owner": "0xowner",
149
+ },
150
+ )
151
+
152
+
153
+ def _mock_remember_accepted(job_id: str = "job-remember") -> httpx.Response:
154
+ return httpx.Response(202, json={"job_id": job_id, "status": "pending"})
155
+
156
+
134
157
  # ============================================================
135
158
  # Pure function tests (no mocking needed)
136
159
  # ============================================================
@@ -1363,3 +1386,246 @@ class TestPendingSaves:
1363
1386
  else:
1364
1387
  raise AssertionError("flush_sync did not raise")
1365
1388
 
1389
+
1390
+ # ============================================================
1391
+ # WALM-307: auto_save save_mode + confirmable saves
1392
+ # ============================================================
1393
+
1394
+
1395
+ class TestAutoSaveMode:
1396
+ """analyze() silently stores nothing for non-fact content (GH #411);
1397
+ save_mode="remember" must store it verbatim instead."""
1398
+
1399
+ def _memwal(self) -> MemWal:
1400
+ return MemWal.create(
1401
+ key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, namespace="default"
1402
+ )
1403
+
1404
+ @respx.mock
1405
+ async def test_analyze_mode_reports_zero_facts_through_wrapper_log(self) -> None:
1406
+ _mock_seal_session_prereqs()
1407
+ respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
1408
+
1409
+ pending = _PendingSaves()
1410
+ messages: list = []
1411
+ with patch("memwal.middleware.logger") as mock_logger:
1412
+ await _run_auto_save(
1413
+ self._memwal(), "def f():\n return 1", "default", "analyze",
1414
+ pending, messages.append,
1415
+ )
1416
+
1417
+ assert not mock_logger.warning.called
1418
+ assert len(messages) == 1
1419
+ assert "save_mode" in messages[0]
1420
+ assert pending.drain_job_ids() == []
1421
+
1422
+ @respx.mock
1423
+ async def test_analyze_mode_tracks_job_ids_without_warning(self) -> None:
1424
+ _mock_seal_session_prereqs()
1425
+ respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze_with_facts("j1", "j2"))
1426
+
1427
+ pending = _PendingSaves()
1428
+ with patch("memwal.middleware.logger") as mock_logger:
1429
+ await _run_auto_save(
1430
+ self._memwal(), "I am allergic to peanuts", "default", "analyze",
1431
+ pending, lambda *_: None,
1432
+ )
1433
+
1434
+ assert not mock_logger.warning.called
1435
+ assert pending.drain_job_ids() == ["j1", "j2"]
1436
+
1437
+ @respx.mock
1438
+ async def test_remember_mode_stores_verbatim_and_skips_analyze(self) -> None:
1439
+ _mock_seal_session_prereqs()
1440
+ analyze_route = respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
1441
+ remember_route = respx.post(_REMEMBER_URL).mock(
1442
+ return_value=_mock_remember_accepted("job-1")
1443
+ )
1444
+
1445
+ pending = _PendingSaves()
1446
+ await _run_auto_save(
1447
+ self._memwal(), "def f():\n return 1", "default", "remember",
1448
+ pending, lambda *_: None,
1449
+ )
1450
+
1451
+ assert remember_route.called
1452
+ assert not analyze_route.called
1453
+ assert pending.drain_job_ids() == ["job-1"]
1454
+
1455
+ def test_invalid_save_mode_rejected_at_wrap_time(self) -> None:
1456
+ client = MagicMock()
1457
+ try:
1458
+ with_memwal_openai(
1459
+ client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER,
1460
+ save_mode="verbatim",
1461
+ )
1462
+ except ValueError as e:
1463
+ assert "save_mode" in str(e)
1464
+ else:
1465
+ raise AssertionError("expected ValueError for an unknown save_mode")
1466
+
1467
+ def test_drain_job_ids_is_one_shot(self) -> None:
1468
+ pending = _PendingSaves()
1469
+ pending.track_job_ids(["a", "b"])
1470
+ assert pending.drain_job_ids() == ["a", "b"]
1471
+ assert pending.drain_job_ids() == []
1472
+
1473
+ def test_tracked_job_ids_are_capped_keeping_the_newest(self) -> None:
1474
+ pending = _PendingSaves()
1475
+ for i in range(_MAX_TRACKED_JOB_IDS + 5):
1476
+ pending.track_job_ids([f"job-{i}"])
1477
+
1478
+ job_ids = pending.drain_job_ids()
1479
+ assert len(job_ids) == _MAX_TRACKED_JOB_IDS
1480
+ assert job_ids[0] == "job-5"
1481
+ assert job_ids[-1] == f"job-{_MAX_TRACKED_JOB_IDS + 4}"
1482
+
1483
+
1484
+ class TestConfirmableSaves:
1485
+ """GH #410/#412: the easy-setup path had no way to confirm a save
1486
+ landed or read its blob ID without dropping to the low-level client."""
1487
+
1488
+ def _make_async_client(self) -> MagicMock:
1489
+ client = MagicMock()
1490
+ client._async_client = MagicMock()
1491
+ mock_response = MagicMock()
1492
+ mock_response.choices = [MagicMock()]
1493
+ mock_response.choices[0].message.content = "answer"
1494
+ client.chat = MagicMock()
1495
+ client.chat.completions = MagicMock()
1496
+ client.chat.completions.create = AsyncMock(return_value=mock_response)
1497
+ return client
1498
+
1499
+ @respx.mock
1500
+ async def test_wait_for_saves_returns_blob_ids(self) -> None:
1501
+ _mock_seal_session_prereqs()
1502
+ respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
1503
+ respx.post(_REMEMBER_URL).mock(return_value=_mock_remember_accepted("job-1"))
1504
+ respx.post(_BULK_STATUS_URL).mock(
1505
+ return_value=httpx.Response(
1506
+ 200,
1507
+ json={
1508
+ "results": [
1509
+ {"job_id": "job-1", "status": "done", "blob_id": "blob-abc"}
1510
+ ]
1511
+ },
1512
+ )
1513
+ )
1514
+
1515
+ smart = with_memwal_openai(
1516
+ self._make_async_client(), key=_KEY_HEX, account_id=_ACCOUNT_ID,
1517
+ server_url=_SERVER, auto_save=True, save_mode="remember",
1518
+ )
1519
+ await smart.chat.completions.create(
1520
+ model="gpt-4o",
1521
+ messages=[{"role": "user", "content": "def f(): return 1"}],
1522
+ )
1523
+
1524
+ result = await smart.memwal_wait_for_saves(
1525
+ RememberBulkOptions(poll_interval_ms=1, timeout_ms=5000)
1526
+ )
1527
+
1528
+ assert result.total == 1
1529
+ assert result.succeeded == 1
1530
+ assert result.results[0].blob_id == "blob-abc"
1531
+
1532
+ @respx.mock
1533
+ async def test_wrapper_exposes_low_level_client_publicly(self) -> None:
1534
+ _mock_seal_session_prereqs()
1535
+ smart = with_memwal_openai(
1536
+ self._make_async_client(), key=_KEY_HEX, account_id=_ACCOUNT_ID,
1537
+ server_url=_SERVER, auto_save=False,
1538
+ )
1539
+
1540
+ assert isinstance(smart.memwal, MemWal)
1541
+ assert callable(smart.memwal.wait_for_remember_jobs)
1542
+ assert callable(smart.memwal_wait_for_saves)
1543
+ assert callable(smart.memwal_wait_for_saves_sync)
1544
+
1545
+ @respx.mock
1546
+ async def test_failed_wait_leaves_job_ids_for_the_next_wait(self) -> None:
1547
+ _mock_seal_session_prereqs()
1548
+ respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
1549
+ respx.post(_REMEMBER_URL).mock(return_value=_mock_remember_accepted("job-1"))
1550
+ status_route = respx.post(_BULK_STATUS_URL).mock(
1551
+ return_value=httpx.Response(403, json={"error": "forbidden"})
1552
+ )
1553
+
1554
+ smart = with_memwal_openai(
1555
+ self._make_async_client(), key=_KEY_HEX, account_id=_ACCOUNT_ID,
1556
+ server_url=_SERVER, auto_save=True, save_mode="remember",
1557
+ )
1558
+ await smart.chat.completions.create(
1559
+ model="gpt-4o",
1560
+ messages=[{"role": "user", "content": "def f(): return 1"}],
1561
+ )
1562
+
1563
+ opts = RememberBulkOptions(poll_interval_ms=1, timeout_ms=5000)
1564
+ try:
1565
+ await smart.memwal_wait_for_saves(opts)
1566
+ except Exception:
1567
+ pass
1568
+ else:
1569
+ raise AssertionError("expected the failing poll to raise")
1570
+
1571
+ status_route.mock(
1572
+ return_value=httpx.Response(
1573
+ 200,
1574
+ json={
1575
+ "results": [
1576
+ {"job_id": "job-1", "status": "done", "blob_id": "blob-abc"}
1577
+ ]
1578
+ },
1579
+ )
1580
+ )
1581
+ result = await smart.memwal_wait_for_saves(opts)
1582
+
1583
+ assert result.total == 1
1584
+ assert result.succeeded == 1
1585
+ assert result.results[0].blob_id == "blob-abc"
1586
+
1587
+ @respx.mock
1588
+ async def test_wait_confirms_more_jobs_than_the_relayer_bulk_cap(self) -> None:
1589
+ """21 tracked jobs must confirm, not 400-loop.
1590
+
1591
+ `/api/remember/bulk/status` rejects more than `MAX_BULK_ITEMS` (20)
1592
+ ids with a 400. 400 is not transient, so an unchunked poll raised,
1593
+ the ids were restored for the retry, and every retry hit the same
1594
+ 400 — a wait that could never confirm. `save_mode="remember"` is one
1595
+ job per turn, so 21 turns reach this.
1596
+ """
1597
+ _mock_seal_session_prereqs()
1598
+ job_ids = [f"job-{i}" for i in range(21)]
1599
+ seen_chunk_sizes: list[int] = []
1600
+
1601
+ def _status(request: httpx.Request) -> httpx.Response:
1602
+ body = json.loads(request.content)
1603
+ ids = body["job_ids"]
1604
+ seen_chunk_sizes.append(len(ids))
1605
+ if len(ids) > 20:
1606
+ return httpx.Response(
1607
+ 400, json={"error": "job_ids exceeds MAX_BULK_ITEMS"}
1608
+ )
1609
+ return httpx.Response(
1610
+ 200,
1611
+ json={
1612
+ "results": [
1613
+ {"job_id": jid, "status": "done", "blob_id": f"blob-{jid}"}
1614
+ for jid in ids
1615
+ ]
1616
+ },
1617
+ )
1618
+
1619
+ respx.post(_BULK_STATUS_URL).mock(side_effect=_status)
1620
+
1621
+ client = MemWal.create(key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER)
1622
+ result = await client.wait_for_remember_jobs(
1623
+ job_ids, RememberBulkOptions(poll_interval_ms=1, timeout_ms=5000)
1624
+ )
1625
+
1626
+ assert max(seen_chunk_sizes) <= 20, seen_chunk_sizes
1627
+ assert result.total == 21
1628
+ assert result.succeeded == 21
1629
+ assert result.timed_out == 0
1630
+ # Order still follows the caller's input, chunking notwithstanding.
1631
+ assert [r.id for r in result.results] == job_ids
@@ -0,0 +1,238 @@
1
+ """#1218: non-finite vectors and bad limits must fail client-side.
2
+
3
+ json.dumps emits bare NaN/Infinity tokens, which are not JSON, so the
4
+ relayer rejected the body with an opaque parse error. Limits went out as
5
+ NaN, -5 or ?limit=nan.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import httpx
13
+ import pytest
14
+
15
+ from memwal import (
16
+ MemWal,
17
+ MemWalError,
18
+ MemWalMock,
19
+ RecallManualOptions,
20
+ RecallParams,
21
+ RememberManualOptions,
22
+ )
23
+
24
+
25
+ class CaptureMemWal(MemWal):
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self.captured = []
29
+
30
+ async def _ensure_compatible_relayer(self):
31
+ return {
32
+ "apiVersion": "1.0.0",
33
+ "relayerVersion": "capture",
34
+ "minSupportedSdk": {"python": "0.0.0"},
35
+ }
36
+
37
+ async def _resolve_owner(self):
38
+ return "0xowner"
39
+
40
+ async def _signed_request(
41
+ self,
42
+ method,
43
+ path,
44
+ body,
45
+ accepted_statuses=(200,),
46
+ include_seal_session=True,
47
+ timeout=None,
48
+ ):
49
+ self.captured.append((method, path, body))
50
+ if path.startswith("/v1/owners"):
51
+ return {"namespaces": [], "has_more": False, "snapshot_version": 1}
52
+ if path == "/api/remember/manual":
53
+ return {"id": "1", "blob_id": "b", "owner": "0x1", "namespace": "default"}
54
+ if path == "/api/restore":
55
+ return {"restored": 0, "skipped": 0, "total": 0, "namespace": "ns", "owner": "0x1"}
56
+ return {"results": [], "total": 0}
57
+
58
+
59
+ class FreshOwnerMemWal(MemWal):
60
+ """Keeps the real ``_resolve_owner``, so owner lookup goes through signing.
61
+
62
+ Every signed request is recorded and then fails, like a relayer outage:
63
+ a bad limit must surface as ValueError, not as this error.
64
+ """
65
+
66
+ def __init__(self, *args, **kwargs):
67
+ super().__init__(*args, **kwargs)
68
+ self.signed = []
69
+
70
+ async def _ensure_compatible_relayer(self):
71
+ return {
72
+ "apiVersion": "1.0.0",
73
+ "relayerVersion": "capture",
74
+ "minSupportedSdk": {"python": "0.0.0"},
75
+ }
76
+
77
+ async def _signed_request(
78
+ self,
79
+ method,
80
+ path,
81
+ body,
82
+ accepted_statuses=(200,),
83
+ include_seal_session=True,
84
+ timeout=None,
85
+ ):
86
+ self.signed.append((method, path))
87
+ raise MemWalError("relayer unavailable")
88
+
89
+
90
+ def _client():
91
+ return CaptureMemWal.create(
92
+ key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
93
+ )
94
+
95
+
96
+ BAD_VECTORS = [
97
+ [0.1, math.nan],
98
+ [math.inf],
99
+ [0.1, -math.inf],
100
+ [],
101
+ [0.1, "0.2"],
102
+ [0.1, None],
103
+ [True, 0.2],
104
+ "0.1,0.2",
105
+ None,
106
+ ]
107
+ BAD_LIMITS = [math.nan, math.inf, -5, -1, 0, 2.5, "5", True]
108
+
109
+
110
+ @pytest.mark.parametrize("vector", BAD_VECTORS, ids=repr)
111
+ async def test_recall_manual_rejects_bad_vector(vector):
112
+ c = _client()
113
+ with pytest.raises(ValueError, match="vector"):
114
+ await c.recall_manual(RecallManualOptions(vector=vector))
115
+ assert c.captured == []
116
+
117
+
118
+ @pytest.mark.parametrize("vector", BAD_VECTORS, ids=repr)
119
+ async def test_remember_manual_rejects_bad_vector(vector):
120
+ c = _client()
121
+ with pytest.raises(ValueError, match="vector"):
122
+ await c.remember_manual(RememberManualOptions(encrypted_data="AAAA", vector=vector))
123
+ assert c.captured == []
124
+
125
+
126
+ @pytest.mark.parametrize("limit", BAD_LIMITS, ids=repr)
127
+ @pytest.mark.parametrize(
128
+ "call",
129
+ [
130
+ ("recall", lambda c, n: c.recall("q", limit=n)),
131
+ ("recall_params", lambda c, n: c.recall(RecallParams(query="q", limit=n))),
132
+ ("recall_manual", lambda c, n: c.recall_manual(RecallManualOptions(vector=[0.1], limit=n))),
133
+ ("restore", lambda c, n: c.restore("ns", limit=n)),
134
+ ("list_namespaces", lambda c, n: c.list_namespaces(limit=n)),
135
+ ],
136
+ ids=lambda x: x[0],
137
+ )
138
+ async def test_bad_limit_is_rejected_before_signing(call, limit):
139
+ c = _client()
140
+ with pytest.raises(ValueError, match="limit"):
141
+ await call[1](c, limit)
142
+ assert c.captured == []
143
+
144
+
145
+ async def test_valid_inputs_still_go_out_unchanged():
146
+ c = _client()
147
+ await c.recall_manual(RecallManualOptions(vector=[0.1, -2, 0], limit=3))
148
+ await c.remember_manual(RememberManualOptions(encrypted_data="AAAA", vector=[1, 0.5]))
149
+ await c.recall("q", limit=1)
150
+ await c.restore("ns", limit=50)
151
+ await c.list_namespaces(limit=500)
152
+ await c.list_namespaces()
153
+ bodies = [b for _, _, b in c.captured]
154
+ assert bodies[0]["vector"] == [0.1, -2, 0] and bodies[0]["limit"] == 3
155
+ assert bodies[1]["vector"] == [1, 0.5]
156
+ assert bodies[2]["limit"] == 1
157
+ assert bodies[3]["limit"] == 50
158
+ assert c.captured[4][1].endswith("?limit=500")
159
+ assert "limit" not in c.captured[5][1]
160
+
161
+
162
+ async def test_signed_request_never_emits_nan_tokens(monkeypatch):
163
+ """Backstop: even if a NaN reaches the signer, it must not be serialised."""
164
+ client = MemWal.create(
165
+ key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
166
+ )
167
+
168
+ async def compatible():
169
+ return {
170
+ "apiVersion": "1.0.0",
171
+ "relayerVersion": "t",
172
+ "minSupportedSdk": {"python": "0.0.0"},
173
+ }
174
+
175
+ sent = []
176
+
177
+ async def fake_post(*args, **kwargs): # pragma: no cover - must not be reached
178
+ sent.append(kwargs)
179
+ raise AssertionError("request sent with a non-JSON body")
180
+
181
+ monkeypatch.setattr(client, "_ensure_compatible_relayer", compatible)
182
+ monkeypatch.setattr(httpx.AsyncClient, "send", fake_post)
183
+ with pytest.raises(ValueError):
184
+ await client._signed_request(
185
+ "POST", "/api/recall", {"limit": math.nan}, include_seal_session=False
186
+ )
187
+ assert sent == []
188
+ await client.close()
189
+
190
+
191
+ @pytest.mark.parametrize("limit", BAD_LIMITS, ids=repr)
192
+ async def test_list_namespaces_bad_limit_raises_before_owner_lookup(limit):
193
+ """The owner lookup is a signed POST /api/stats; a bad limit must not reach it."""
194
+ c = FreshOwnerMemWal.create(
195
+ key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
196
+ )
197
+ with pytest.raises(ValueError, match="limit"):
198
+ await c.list_namespaces(limit=limit)
199
+ assert c.signed == []
200
+ await c.close()
201
+
202
+
203
+ async def test_list_namespaces_valid_limit_still_resolves_owner():
204
+ c = FreshOwnerMemWal.create(
205
+ key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
206
+ )
207
+ with pytest.raises(MemWalError, match="relayer unavailable"):
208
+ await c.list_namespaces(limit=5)
209
+ assert c.signed == [("POST", "/api/stats")]
210
+ await c.close()
211
+
212
+
213
+ @pytest.mark.parametrize("limit", BAD_LIMITS, ids=repr)
214
+ @pytest.mark.parametrize(
215
+ "call",
216
+ [
217
+ ("recall", lambda m, n: m.recall("q", limit=n)),
218
+ ("recall_params", lambda m, n: m.recall(RecallParams(query="q", limit=n))),
219
+ ("ask", lambda m, n: m.ask("q", limit=n)),
220
+ ("restore", lambda m, n: m.restore("ns", limit=n)),
221
+ ("list_namespaces", lambda m, n: m.list_namespaces(limit=n)),
222
+ ],
223
+ ids=lambda x: x[0],
224
+ )
225
+ async def test_mock_rejects_the_same_bad_limits_as_the_client(call, limit):
226
+ mock = MemWalMock.create()
227
+ await mock.remember("a fact")
228
+ with pytest.raises(ValueError, match="limit"):
229
+ await call[1](mock, limit)
230
+
231
+
232
+ async def test_mock_accepts_positive_integer_limits():
233
+ mock = MemWalMock.create()
234
+ await mock.remember("a fact")
235
+ assert len((await mock.recall("fact", limit=1)).results) == 1
236
+ assert (await mock.restore("ns", limit=1)).restored == 0
237
+ assert len((await mock.list_namespaces(limit=1)).namespaces) == 1
238
+ assert len((await mock.list_namespaces()).namespaces) == 1
File without changes
File without changes
File without changes
File without changes
File without changes