memwal 0.2.0.dev0__py3-none-any.whl → 0.2.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memwal/__init__.py +1 -1
- memwal/client.py +102 -43
- memwal/middleware.py +161 -23
- memwal/mock.py +5 -3
- {memwal-0.2.0.dev0.dist-info → memwal-0.2.1.dev0.dist-info}/METADATA +1 -1
- memwal-0.2.1.dev0.dist-info/RECORD +10 -0
- memwal-0.2.0.dev0.dist-info/RECORD +0 -10
- {memwal-0.2.0.dev0.dist-info → memwal-0.2.1.dev0.dist-info}/WHEEL +0 -0
memwal/__init__.py
CHANGED
memwal/client.py
CHANGED
|
@@ -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
|
-
|
|
911
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
if err.status
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
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
|
|
1009
|
+
if item.job_id in pending_now and settle(item)
|
|
962
1010
|
}
|
|
963
|
-
|
|
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(
|
|
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"
|
memwal/middleware.py
CHANGED
|
@@ -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,48 @@ 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 BaseException:
|
|
442
|
+
# Cancellation and interrupts must leave unconfirmed jobs available
|
|
443
|
+
# to the next wait, while still propagating to the caller.
|
|
444
|
+
pending.track_job_ids(job_ids)
|
|
445
|
+
raise
|
|
446
|
+
|
|
447
|
+
def memwal_wait_for_saves_sync(
|
|
448
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
449
|
+
) -> RememberBulkResult:
|
|
450
|
+
"""Sync counterpart of ``memwal_wait_for_saves`` for callers on the
|
|
451
|
+
blocking entry points (``OpenAI``, ``llm.invoke``)."""
|
|
452
|
+
pending.flush_sync()
|
|
453
|
+
job_ids = pending.drain_job_ids()
|
|
454
|
+
try:
|
|
455
|
+
return _run_blocking(
|
|
456
|
+
lambda: _with_fresh_http_client(
|
|
457
|
+
memwal, memwal.wait_for_remember_jobs(job_ids, opts)
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
except BaseException:
|
|
461
|
+
# Cancellation and interrupts must leave unconfirmed jobs available
|
|
462
|
+
# to the next wait, while still propagating to the caller.
|
|
463
|
+
pending.track_job_ids(job_ids)
|
|
464
|
+
raise
|
|
465
|
+
|
|
352
466
|
object.__setattr__(obj, "_memwal", memwal)
|
|
467
|
+
# Public alias: reaching wait_for_remember_jobs() and the rest of the
|
|
468
|
+
# low-level client shouldn't require touching a private attribute.
|
|
469
|
+
object.__setattr__(obj, "memwal", memwal)
|
|
353
470
|
object.__setattr__(obj, "memwal_flush", pending.flush)
|
|
354
471
|
object.__setattr__(obj, "memwal_flush_sync", pending.flush_sync)
|
|
472
|
+
object.__setattr__(obj, "memwal_wait_for_saves", memwal_wait_for_saves)
|
|
473
|
+
object.__setattr__(obj, "memwal_wait_for_saves_sync", memwal_wait_for_saves_sync)
|
|
355
474
|
|
|
356
475
|
|
|
357
476
|
async def _warn_if_cancelled(coro: Any, label: str) -> None:
|
|
@@ -416,6 +535,7 @@ def with_memwal_langchain(
|
|
|
416
535
|
namespace: str = "default",
|
|
417
536
|
max_memories: int = 5,
|
|
418
537
|
auto_save: bool = True,
|
|
538
|
+
save_mode: str = "analyze",
|
|
419
539
|
min_relevance: float = 0.3,
|
|
420
540
|
debug: bool = False,
|
|
421
541
|
env: Optional[str] = None,
|
|
@@ -438,6 +558,11 @@ def with_memwal_langchain(
|
|
|
438
558
|
namespace: Default namespace.
|
|
439
559
|
max_memories: Max memories to inject per request.
|
|
440
560
|
auto_save: Auto-save new facts from conversation.
|
|
561
|
+
save_mode: How auto_save persists the user message. ``"analyze"``
|
|
562
|
+
(default) extracts spoken-fact-style statements and stores one
|
|
563
|
+
memory per fact — non-sentence content such as code yields zero
|
|
564
|
+
facts and stores nothing. ``"remember"`` stores the message
|
|
565
|
+
verbatim as a single memory.
|
|
441
566
|
min_relevance: Minimum similarity score (0-1) to include a memory.
|
|
442
567
|
debug: Enable debug logging.
|
|
443
568
|
env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
|
|
@@ -458,6 +583,8 @@ def with_memwal_langchain(
|
|
|
458
583
|
"Install with: pip install memwal[langchain]"
|
|
459
584
|
) from e
|
|
460
585
|
|
|
586
|
+
_validate_save_mode(save_mode)
|
|
587
|
+
|
|
461
588
|
memwal = MemWal.create(
|
|
462
589
|
key=key,
|
|
463
590
|
account_id=account_id,
|
|
@@ -552,16 +679,12 @@ def with_memwal_langchain(
|
|
|
552
679
|
return messages
|
|
553
680
|
|
|
554
681
|
async def _post_analyze(messages: List[BaseMessage]) -> None:
|
|
555
|
-
"""
|
|
682
|
+
"""Persist the user message via the configured save mode."""
|
|
556
683
|
if not auto_save or not _is_last_message_user(messages):
|
|
557
684
|
return
|
|
558
685
|
user_text = _find_last_user_message(messages)
|
|
559
686
|
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)
|
|
687
|
+
await _run_auto_save(memwal, user_text, namespace, save_mode, pending, log)
|
|
565
688
|
|
|
566
689
|
async def patched_agenerate(
|
|
567
690
|
messages: List[BaseMessage], *args: Any, **kwargs: Any
|
|
@@ -604,6 +727,7 @@ def with_memwal_openai(
|
|
|
604
727
|
namespace: str = "default",
|
|
605
728
|
max_memories: int = 5,
|
|
606
729
|
auto_save: bool = True,
|
|
730
|
+
save_mode: str = "analyze",
|
|
607
731
|
min_relevance: float = 0.3,
|
|
608
732
|
debug: bool = False,
|
|
609
733
|
env: Optional[str] = None,
|
|
@@ -628,6 +752,11 @@ def with_memwal_openai(
|
|
|
628
752
|
namespace: Default namespace.
|
|
629
753
|
max_memories: Max memories to inject per request.
|
|
630
754
|
auto_save: Auto-save new facts from conversation.
|
|
755
|
+
save_mode: How auto_save persists the user message. ``"analyze"``
|
|
756
|
+
(default) extracts spoken-fact-style statements and stores one
|
|
757
|
+
memory per fact — non-sentence content such as code yields zero
|
|
758
|
+
facts and stores nothing. ``"remember"`` stores the message
|
|
759
|
+
verbatim as a single memory.
|
|
631
760
|
min_relevance: Minimum similarity score (0-1) to include a memory.
|
|
632
761
|
debug: Enable debug logging.
|
|
633
762
|
env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
|
|
@@ -639,6 +768,8 @@ def with_memwal_openai(
|
|
|
639
768
|
Returns:
|
|
640
769
|
The same client, with ``chat.completions.create`` wrapped to use Walrus Memory.
|
|
641
770
|
"""
|
|
771
|
+
_validate_save_mode(save_mode)
|
|
772
|
+
|
|
642
773
|
memwal = MemWal.create(
|
|
643
774
|
key=key,
|
|
644
775
|
account_id=account_id,
|
|
@@ -654,11 +785,13 @@ def with_memwal_openai(
|
|
|
654
785
|
|
|
655
786
|
if is_async:
|
|
656
787
|
_wrap_async_openai(
|
|
657
|
-
client, memwal, namespace, max_memories, auto_save,
|
|
788
|
+
client, memwal, namespace, max_memories, auto_save, save_mode,
|
|
789
|
+
min_relevance, log, pending,
|
|
658
790
|
)
|
|
659
791
|
else:
|
|
660
792
|
_wrap_sync_openai(
|
|
661
|
-
client, memwal, namespace, max_memories, auto_save,
|
|
793
|
+
client, memwal, namespace, max_memories, auto_save, save_mode,
|
|
794
|
+
min_relevance, log, pending,
|
|
662
795
|
)
|
|
663
796
|
|
|
664
797
|
_expose_memwal_controls(client, memwal, pending)
|
|
@@ -672,6 +805,7 @@ def _wrap_async_openai(
|
|
|
672
805
|
namespace: str,
|
|
673
806
|
max_memories: int,
|
|
674
807
|
auto_save: bool,
|
|
808
|
+
save_mode: str,
|
|
675
809
|
min_relevance: float,
|
|
676
810
|
log: Callable[..., Any],
|
|
677
811
|
pending: _PendingSaves,
|
|
@@ -725,18 +859,14 @@ def _wrap_async_openai(
|
|
|
725
859
|
|
|
726
860
|
result = await original_create(*args, **kwargs)
|
|
727
861
|
|
|
728
|
-
# Fire-and-forget
|
|
862
|
+
# Fire-and-forget auto-save
|
|
729
863
|
if auto_save and _is_last_message_user(messages):
|
|
730
864
|
user_text = _find_last_user_message(messages)
|
|
731
865
|
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)
|
|
866
|
+
_fire_and_forget(
|
|
867
|
+
_run_auto_save(memwal, user_text, namespace, save_mode, pending, log),
|
|
868
|
+
pending,
|
|
869
|
+
)
|
|
740
870
|
|
|
741
871
|
return result
|
|
742
872
|
|
|
@@ -749,6 +879,7 @@ def _wrap_sync_openai(
|
|
|
749
879
|
namespace: str,
|
|
750
880
|
max_memories: int,
|
|
751
881
|
auto_save: bool,
|
|
882
|
+
save_mode: str,
|
|
752
883
|
min_relevance: float,
|
|
753
884
|
log: Callable[..., Any],
|
|
754
885
|
pending: _PendingSaves,
|
|
@@ -807,18 +938,25 @@ def _wrap_sync_openai(
|
|
|
807
938
|
|
|
808
939
|
result = original_create(*args, **kwargs)
|
|
809
940
|
|
|
810
|
-
# Fire-and-forget
|
|
941
|
+
# Fire-and-forget auto-save
|
|
811
942
|
if auto_save and _is_last_message_user(messages):
|
|
812
943
|
user_text = _find_last_user_message(messages)
|
|
813
944
|
if user_text:
|
|
814
|
-
def
|
|
945
|
+
def _save() -> None:
|
|
946
|
+
# _run_auto_save swallows request failures itself; this guards
|
|
947
|
+
# the loop/http-client plumbing around it so a failure there
|
|
948
|
+
# can't take down the background thread.
|
|
815
949
|
try:
|
|
816
|
-
_run_memwal(
|
|
817
|
-
|
|
950
|
+
_run_memwal(
|
|
951
|
+
lambda: _run_auto_save(
|
|
952
|
+
memwal, user_text, namespace, save_mode, pending, log
|
|
953
|
+
)
|
|
954
|
+
)
|
|
955
|
+
except Exception as e: # noqa: BLE001
|
|
818
956
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
819
957
|
pending.note_error(e)
|
|
820
958
|
|
|
821
|
-
pending.spawn_thread(
|
|
959
|
+
pending.spawn_thread(_save)
|
|
822
960
|
|
|
823
961
|
return result
|
|
824
962
|
|
memwal/mock.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1.dev0
|
|
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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
memwal/__init__.py,sha256=IAJWdWRnUVTD5bZN7EVWQ6Q511j27npZgD5aG_DMltk,3142
|
|
2
|
+
memwal/client.py,sha256=GryxHSCyGjDmlWOTBSJp7J7ItopwUs0D1JdynrgjYTk,88398
|
|
3
|
+
memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
|
|
4
|
+
memwal/middleware.py,sha256=z2xdbvaobBg4byJXnrX7amWdVYE4jfeO4t31DoDBv_o,38413
|
|
5
|
+
memwal/mock.py,sha256=5bXVej1Xs1a3o1tLL0Vvr1QB9IXyRUTBM7qooTEtysQ,24015
|
|
6
|
+
memwal/types.py,sha256=xvs9BqFLXDsfkHp3-8Bd_Pv5RhDDWOKr0-bUsvic1P4,13732
|
|
7
|
+
memwal/utils.py,sha256=Dyv-DWIMwivtYLxruHXlNXE9i8hZu8QDv1TapVKMeOQ,12064
|
|
8
|
+
memwal-0.2.1.dev0.dist-info/METADATA,sha256=OiHVIIM8Tbpf4Mj0dsKObxQfxZD_1NqshTsKGthp9hE,10346
|
|
9
|
+
memwal-0.2.1.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
memwal-0.2.1.dev0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
memwal/__init__.py,sha256=9v3THq6hHmf2gdlipB_r_M_o7rL8VH2FG9MVBBd7sjU,3142
|
|
2
|
-
memwal/client.py,sha256=_cvh1Ye3cdf25wsioLBV7IKKeGonkF6TZd3w0UnUtR8,85589
|
|
3
|
-
memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
|
|
4
|
-
memwal/middleware.py,sha256=3KM_TdgAJ2EwG4uBrG-71gpEoLw5Ks3GFTmk9wZFsOc,32553
|
|
5
|
-
memwal/mock.py,sha256=295USgDm14SfeOUMjx8bdsvjjGTRgl1FR34DYbGmWGM,24003
|
|
6
|
-
memwal/types.py,sha256=xvs9BqFLXDsfkHp3-8Bd_Pv5RhDDWOKr0-bUsvic1P4,13732
|
|
7
|
-
memwal/utils.py,sha256=Dyv-DWIMwivtYLxruHXlNXE9i8hZu8QDv1TapVKMeOQ,12064
|
|
8
|
-
memwal-0.2.0.dev0.dist-info/METADATA,sha256=4y1c1a7ha5QjGeebDc43s_ZpZDLLDuZs-Sh6Mfj4g3Y,10346
|
|
9
|
-
memwal-0.2.0.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
-
memwal-0.2.0.dev0.dist-info/RECORD,,
|
|
File without changes
|