memwal 0.2.0__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 → memwal-0.2.0.dev1}/CHANGELOG.md +5 -0
  2. {memwal-0.2.0 → memwal-0.2.0.dev1}/PKG-INFO +1 -1
  3. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/__init__.py +1 -1
  4. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/client.py +118 -51
  5. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/middleware.py +157 -23
  6. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/mock.py +9 -5
  7. {memwal-0.2.0 → memwal-0.2.0.dev1}/pyproject.toml +1 -1
  8. memwal-0.2.0.dev1/tests/test_empty_namespace.py +144 -0
  9. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_middleware.py +267 -1
  10. memwal-0.2.0.dev1/tests/test_nonfinite_validation.py +238 -0
  11. {memwal-0.2.0 → memwal-0.2.0.dev1}/.gitignore +0 -0
  12. {memwal-0.2.0 → memwal-0.2.0.dev1}/README.md +0 -0
  13. {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/.env.example +0 -0
  14. {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/.gitignore +0 -0
  15. {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/async_remember_demo.py +0 -0
  16. {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/interactive_demo.py +0 -0
  17. {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/verify_credentials.py +0 -0
  18. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/compatibility.py +0 -0
  19. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/types.py +0 -0
  20. {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/utils.py +0 -0
  21. {memwal-0.2.0 → memwal-0.2.0.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  22. {memwal-0.2.0 → memwal-0.2.0.dev1}/run_tests.py +0 -0
  23. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/__init__.py +0 -0
  24. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_auth_rejected_message.py +0 -0
  25. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_blank_text.py +0 -0
  26. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_client.py +0 -0
  27. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_env_presets.py +0 -0
  28. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_integration.py +0 -0
  29. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_list_namespaces.py +0 -0
  30. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_mock.py +0 -0
  31. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_normalize_server_url.py +0 -0
  32. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_polling_delay.py +0 -0
  33. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_recall_weights.py +0 -0
  34. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_remember_derived_key.py +0 -0
  35. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_retry_after_http_date.py +0 -0
  36. {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_signing.py +0 -0
  37. {memwal-0.2.0 → 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
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"
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
 
@@ -435,6 +462,14 @@ class MemWal:
435
462
  # subsequent identical call then collapses onto the accepted paid job.
436
463
  self._pending_remember_keys: Dict[str, str] = {}
437
464
 
465
+ def _resolve_namespace(self, namespace: Optional[str]) -> str:
466
+ """``None`` uses the client default; do not treat ``""`` as missing."""
467
+ if namespace is None:
468
+ return self._namespace
469
+ if not namespace:
470
+ raise ValueError("namespace cannot be empty")
471
+ return namespace
472
+
438
473
  @classmethod
439
474
  def create(
440
475
  cls,
@@ -514,7 +549,7 @@ class MemWal:
514
549
  status (``"pending"``).
515
550
  """
516
551
  _reject_blank_text(text)
517
- resolved_namespace = namespace or self._namespace
552
+ resolved_namespace = self._resolve_namespace(namespace)
518
553
  request_identity = f"{resolved_namespace}\0{text}"
519
554
  generated_key = idempotency_key is None
520
555
  resolved_key = (
@@ -727,7 +762,7 @@ class MemWal:
727
762
  """
728
763
 
729
764
  _reject_blank_text(text)
730
- resolved_namespace = namespace or self._namespace
765
+ resolved_namespace = self._resolve_namespace(namespace)
731
766
  request_identity = f"{resolved_namespace}\0{text}"
732
767
  generated_key = idempotency_key is None
733
768
  resolved_key = (
@@ -773,7 +808,7 @@ class MemWal:
773
808
  payload_items: List[Dict[str, Any]] = [
774
809
  {
775
810
  "text": item.text,
776
- "namespace": item.namespace or self._namespace,
811
+ "namespace": self._resolve_namespace(item.namespace),
777
812
  }
778
813
  for item in items
779
814
  ]
@@ -899,60 +934,81 @@ class MemWal:
899
934
  attempt += 1
900
935
  retry_after_ms = 0
901
936
 
902
- try:
903
- 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]
904
946
  try:
905
- batch = await self.get_remember_bulk_status(pending, timeout=poll_timeout)
906
- except TypeError as err:
907
- if "unexpected keyword argument" in str(err) and "timeout" in str(err):
908
- batch = await self.get_remember_bulk_status(pending)
909
- else:
910
- raise
911
- except _HttpStatusError as err:
912
- if _is_transient_polling_status(err.status):
913
- last_refusal = err.status
914
- if err.status == 429:
915
- saw_rate_limit = True
916
- 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)
917
970
  continue
918
- raise
919
- except httpx.TransportError:
920
- # Network drops retry as status 0; continuing next loop iteration
921
- # increments `attempt` so exponential backoff delay continues to grow.
922
- last_refusal = 0
923
- continue
924
- 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
+ }
925
978
 
926
- terminal_ids: Set[str] = {
927
- item.job_id
928
- for item in batch.results
929
- if item.job_id in pending and settle(item)
930
- }
979
+ if not answered and refused_ids:
980
+ # Nothing got through this tick; back off and retry.
981
+ continue
931
982
  pending = [jid for jid in pending if jid not in terminal_ids]
932
983
 
933
984
  if opts.timeout_ms > 0 and len(pending) == len(job_ids) and not last_seen:
934
985
  # Nothing settled and nothing was seen: the polls may all have been
935
986
  # refused. Confirm with one direct read before reporting anything.
936
- try:
937
- probed = await self.get_remember_bulk_status(list(job_ids))
938
- except _HttpStatusError as err:
939
- if err.status == 429:
940
- # Header first, else the body's retry_after_seconds, so a
941
- # proxy that strips Retry-After does not lose the hint.
942
- wait_ms = _retry_after_ms(err)
943
- retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
944
- raise MemWalRateLimited(job_ids, retry_after) from err
945
- # Any other probe failure is not evidence about the jobs.
946
- except httpx.TransportError:
947
- pass
948
- 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
949
1004
  answered = True
950
- settled = {
1005
+ pending_now = set(pending)
1006
+ settled |= {
951
1007
  item.job_id
952
1008
  for item in probed.results
953
- if item.job_id in pending and settle(item)
1009
+ if item.job_id in pending_now and settle(item)
954
1010
  }
955
- pending = [jid for jid in pending if jid not in settled]
1011
+ pending = [jid for jid in pending if jid not in settled]
956
1012
 
957
1013
  for job_id in pending:
958
1014
  seen = last_seen.get(job_id)
@@ -1049,10 +1105,11 @@ class MemWal:
1049
1105
  sort = params.sort
1050
1106
  else:
1051
1107
  query_text = query
1108
+ _check_limit(limit)
1052
1109
  body = {
1053
1110
  "query": query_text,
1054
1111
  "limit": limit,
1055
- "namespace": namespace or self._namespace,
1112
+ "namespace": self._resolve_namespace(namespace),
1056
1113
  }
1057
1114
  if scoring_weights is not None:
1058
1115
  body["scoring_weights"] = scoring_weights.to_wire()
@@ -1127,7 +1184,7 @@ class MemWal:
1127
1184
  _reject_blank_text(text)
1128
1185
  body: Dict[str, Any] = {
1129
1186
  "text": text,
1130
- "namespace": namespace or self._namespace,
1187
+ "namespace": self._resolve_namespace(namespace),
1131
1188
  }
1132
1189
  wire_occurred_at = _occurred_at_to_wire(occurred_at)
1133
1190
  if wire_occurred_at is not None:
@@ -1228,7 +1285,7 @@ class MemWal:
1228
1285
  data = await self._signed_request("POST", "/api/ask", {
1229
1286
  "question": question,
1230
1287
  "limit": limit,
1231
- "namespace": namespace or self._namespace,
1288
+ "namespace": self._resolve_namespace(namespace),
1232
1289
  })
1233
1290
  memories = [
1234
1291
  AskMemory(blob_id=m["blob_id"], text=m["text"], distance=m["distance"])
@@ -1291,6 +1348,7 @@ class MemWal:
1291
1348
  downloads in parallel, then 3 SEAL decrypts in parallel, then
1292
1349
  embeddings. Expect seconds-per-blob on cold caches.
1293
1350
  """
1351
+ _check_limit(limit)
1294
1352
  data = await self._signed_request("POST", "/api/restore", {
1295
1353
  "namespace": namespace,
1296
1354
  "limit": limit,
@@ -1346,6 +1404,10 @@ class MemWal:
1346
1404
  Returns:
1347
1405
  :class:`NamespacesResult`.
1348
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)
1349
1411
  owner = await self._resolve_owner()
1350
1412
 
1351
1413
  params: Dict[str, str] = {}
@@ -1420,13 +1482,14 @@ class MemWal:
1420
1482
  Returns:
1421
1483
  :class:`RememberManualResult` with id, blob_id, owner, namespace.
1422
1484
  """
1485
+ _check_vector(opts.vector)
1423
1486
  data = await self._signed_request(
1424
1487
  "POST",
1425
1488
  "/api/remember/manual",
1426
1489
  {
1427
1490
  "encrypted_data": opts.encrypted_data,
1428
1491
  "vector": opts.vector,
1429
- "namespace": opts.namespace or self._namespace,
1492
+ "namespace": self._resolve_namespace(opts.namespace),
1430
1493
  },
1431
1494
  include_seal_session=False,
1432
1495
  )
@@ -1449,10 +1512,12 @@ class MemWal:
1449
1512
  Returns:
1450
1513
  :class:`RecallManualResult` with blob_id + distance pairs.
1451
1514
  """
1515
+ _check_vector(opts.vector)
1516
+ _check_limit(opts.limit)
1452
1517
  body: Dict[str, Any] = {
1453
1518
  "vector": opts.vector,
1454
1519
  "limit": opts.limit,
1455
- "namespace": opts.namespace or self._namespace,
1520
+ "namespace": self._resolve_namespace(opts.namespace),
1456
1521
  }
1457
1522
  if opts.scoring_weights is not None:
1458
1523
  body["scoring_weights"] = opts.scoring_weights.to_wire()
@@ -1743,7 +1808,9 @@ class MemWal:
1743
1808
 
1744
1809
  method_upper = method.upper()
1745
1810
  timestamp = str(int(time.time()))
1746
- 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
+ )
1747
1814
  body_hash = sha256_hex(body_str)
1748
1815
  # MED-1 / LOW-23: nonce + account_id are part of the canonical signed
1749
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