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.
- {memwal-0.2.0 → memwal-0.2.0.dev1}/CHANGELOG.md +5 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/PKG-INFO +1 -1
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/__init__.py +1 -1
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/client.py +118 -51
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/middleware.py +157 -23
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/mock.py +9 -5
- {memwal-0.2.0 → memwal-0.2.0.dev1}/pyproject.toml +1 -1
- memwal-0.2.0.dev1/tests/test_empty_namespace.py +144 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_middleware.py +267 -1
- memwal-0.2.0.dev1/tests/test_nonfinite_validation.py +238 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/.gitignore +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/README.md +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/.env.example +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/.gitignore +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/async_remember_demo.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/interactive_demo.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/examples/verify_credentials.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/compatibility.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/types.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/memwal/utils.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/run_tests.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/__init__.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_blank_text.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_client.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_env_presets.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_integration.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_list_namespaces.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_mock.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_polling_delay.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_recall_weights.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.2.0 → memwal-0.2.0.dev1}/tests/test_signing.py +0 -0
- {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
|
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
903
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if err.status
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
|
1009
|
+
if item.job_id in pending_now and settle(item)
|
|
954
1010
|
}
|
|
955
|
-
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
"""
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
|
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
|
|
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(
|
|
817
|
-
|
|
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(
|
|
955
|
+
pending.spawn_thread(_save)
|
|
822
956
|
|
|
823
957
|
return result
|
|
824
958
|
|