memwal 0.2.0.dev0__py3-none-any.whl → 0.2.0.dev1__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 +157 -23
- memwal/mock.py +5 -3
- {memwal-0.2.0.dev0.dist-info → memwal-0.2.0.dev1.dist-info}/METADATA +1 -1
- memwal-0.2.0.dev1.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.0.dev1.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,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
|
|
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.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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
memwal/__init__.py,sha256=xeZFU1QIW2nP78hWp5Rn-qtn5XvG0mimA8S1tv3u-cY,3142
|
|
2
|
+
memwal/client.py,sha256=GryxHSCyGjDmlWOTBSJp7J7ItopwUs0D1JdynrgjYTk,88398
|
|
3
|
+
memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
|
|
4
|
+
memwal/middleware.py,sha256=g1ssmQrtxxeRWRZsWCOob8YXMuEttLd0gXzAFSHqxGM,38103
|
|
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.0.dev1.dist-info/METADATA,sha256=t8tqofQdGYyrNKjKAculKMNpaCDHBEiUOp3zUX4gsYU,10346
|
|
9
|
+
memwal-0.2.0.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
memwal-0.2.0.dev1.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
|