memwal 0.1.11.dev13__tar.gz → 0.1.11.dev14__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 (35) hide show
  1. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/CHANGELOG.md +1 -0
  2. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/PKG-INFO +2 -2
  3. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/README.md +1 -1
  4. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/__init__.py +1 -1
  5. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/client.py +11 -2
  6. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/middleware.py +214 -121
  7. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/mock.py +17 -0
  8. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/types.py +2 -0
  9. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/pyproject.toml +1 -1
  10. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_middleware.py +225 -16
  11. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_mock.py +31 -0
  12. memwal-0.1.11.dev14/tests/test_recall_weights.py +42 -0
  13. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/.gitignore +0 -0
  14. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/.env.example +0 -0
  15. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/.gitignore +0 -0
  16. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/async_remember_demo.py +0 -0
  17. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/interactive_demo.py +0 -0
  18. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/verify_credentials.py +0 -0
  19. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/compatibility.py +0 -0
  20. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/utils.py +0 -0
  21. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  22. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/run_tests.py +0 -0
  23. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/__init__.py +0 -0
  24. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_auth_rejected_message.py +0 -0
  25. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_blank_text.py +0 -0
  26. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_client.py +0 -0
  27. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_env_presets.py +0 -0
  28. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_integration.py +0 -0
  29. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_list_namespaces.py +0 -0
  30. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_normalize_server_url.py +0 -0
  31. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_polling_delay.py +0 -0
  32. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_remember_derived_key.py +0 -0
  33. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_retry_after_http_date.py +0 -0
  34. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_signing.py +0 -0
  35. {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_transport_retry.py +0 -0
@@ -5,6 +5,7 @@
5
5
  ### Added
6
6
 
7
7
  - `get_remember_status(job_id)` on `MemWal` and `MemWalSync` returns `RememberJobStatus` from `GET /api/remember/{job_id}`, accepting 200 and 404 like the TypeScript SDK. (WALM-832, #1096)
8
+ - `RecallParams` accepts `scoring_weights` and `sort`. `recall()` forwards them on `POST /api/recall`. (#1062)
8
9
 
9
10
  ### Fixed
10
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.11.dev13
3
+ Version: 0.1.11.dev14
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
@@ -246,7 +246,7 @@ Create a new async client.
246
246
  | `await remember_bulk(items)` | Accept several background remember jobs |
247
247
  | `await wait_for_remember_jobs(job_ids, opts?)` | Poll several remember jobs together |
248
248
  | `await remember_bulk_and_wait(items, opts?)` | Store several memories and wait for completion |
249
- | `await recall(RecallParams(query, limit?, namespace?, max_distance?))` | Search memories, optionally filtering by distance |
249
+ | `await recall(RecallParams(query, limit?, namespace?, max_distance?, scoring_weights?, sort?))` | Search memories, optionally filtering by distance |
250
250
  | `await analyze(text, namespace?)` | Extract and store facts |
251
251
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
252
252
  | `await restore(namespace, limit?)` | Restore a namespace |
@@ -207,7 +207,7 @@ Create a new async client.
207
207
  | `await remember_bulk(items)` | Accept several background remember jobs |
208
208
  | `await wait_for_remember_jobs(job_ids, opts?)` | Poll several remember jobs together |
209
209
  | `await remember_bulk_and_wait(items, opts?)` | Store several memories and wait for completion |
210
- | `await recall(RecallParams(query, limit?, namespace?, max_distance?))` | Search memories, optionally filtering by distance |
210
+ | `await recall(RecallParams(query, limit?, namespace?, max_distance?, scoring_weights?, sort?))` | Search memories, optionally filtering by distance |
211
211
  | `await analyze(text, namespace?)` | Extract and store facts |
212
212
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
213
213
  | `await restore(namespace, limit?)` | Restore a namespace |
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.1.11.dev13"
131
+ __version__ = "0.1.11.dev14"
@@ -1037,19 +1037,28 @@ class MemWal:
1037
1037
  Returns:
1038
1038
  :class:`RecallResult` with decrypted text results.
1039
1039
  """
1040
+ scoring_weights = None
1041
+ sort = None
1040
1042
  if isinstance(query, RecallParams):
1041
1043
  params = query
1042
1044
  query_text = params.query
1043
1045
  limit = params.limit
1044
1046
  namespace = params.namespace
1045
1047
  max_distance = params.max_distance
1048
+ scoring_weights = params.scoring_weights
1049
+ sort = params.sort
1046
1050
  else:
1047
1051
  query_text = query
1048
- data = await self._signed_request("POST", "/api/recall", {
1052
+ body = {
1049
1053
  "query": query_text,
1050
1054
  "limit": limit,
1051
1055
  "namespace": namespace or self._namespace,
1052
- })
1056
+ }
1057
+ if scoring_weights is not None:
1058
+ body["scoring_weights"] = scoring_weights.to_wire()
1059
+ if sort is not None:
1060
+ body["sort"] = sort
1061
+ data = await self._signed_request("POST", "/api/recall", body)
1053
1062
  memories = [
1054
1063
  RecallMemory(
1055
1064
  blob_id=m["blob_id"],
@@ -105,6 +105,54 @@ def _find_last_user_message(messages: Any) -> Optional[str]:
105
105
  return None
106
106
 
107
107
 
108
+ def _is_last_message_user(messages: Any) -> bool:
109
+ """Return True if the last message in `messages` is a user/human turn."""
110
+ if not isinstance(messages, (list, tuple)) or not messages:
111
+ return False
112
+ last = messages[-1]
113
+ if isinstance(last, dict):
114
+ return last.get("role") == "user"
115
+ if hasattr(last, "type"):
116
+ return getattr(last, "type") in ("human", "user")
117
+ if hasattr(last, "role"):
118
+ return getattr(last, "role") == "user"
119
+ return False
120
+
121
+
122
+ def _user_turn_key(messages: Any) -> Optional[str]:
123
+ """Identity of the latest user turn: index plus text.
124
+
125
+ A tool step keeps the same key, so a cached recall can be re-injected
126
+ without calling the relayer again.
127
+ """
128
+ if not isinstance(messages, (list, tuple)) or not messages:
129
+ return None
130
+ text = _find_last_user_message(messages)
131
+ if not text:
132
+ return None
133
+ for i in range(len(messages) - 1, -1, -1):
134
+ msg = messages[i]
135
+ is_user = (
136
+ (isinstance(msg, dict) and msg.get("role") == "user")
137
+ or getattr(msg, "type", None) in ("human", "user")
138
+ or (not isinstance(msg, dict) and getattr(msg, "role", None) == "user")
139
+ )
140
+ if is_user:
141
+ return f"{i}\0{text}"
142
+ return None
143
+
144
+
145
+ _RECALL_CACHE_LIMIT = 8
146
+
147
+
148
+ def _remember_recall(cache: dict[str, str], key: str, context: str) -> None:
149
+ """Keep one entry per turn, newest last, and drop the oldest past the cap."""
150
+ cache.pop(key, None)
151
+ cache[key] = context
152
+ while len(cache) > _RECALL_CACHE_LIMIT:
153
+ cache.pop(next(iter(cache)))
154
+
155
+
108
156
  def _format_memories(
109
157
  memories: List[RecallMemory],
110
158
  nonce: Optional[str] = None,
@@ -420,12 +468,65 @@ def with_memwal_langchain(
420
468
 
421
469
  log = logger.debug if not debug else logger.warning
422
470
  pending = _PendingSaves(on_save_error)
471
+ recall_cache: dict[str, str] = {}
423
472
 
424
473
  original_agenerate = llm._agenerate
425
474
  original_generate = llm._generate
426
475
 
476
+ def _with_memory(messages: List[BaseMessage], memory_context: str) -> List[BaseMessage]:
477
+ result = list(messages)
478
+ last_human_idx = -1
479
+ for i in range(len(result) - 1, -1, -1):
480
+ if isinstance(result[i], HumanMessage):
481
+ last_human_idx = i
482
+ break
483
+
484
+ memory_msg = HumanMessage(content=memory_context)
485
+ if last_human_idx > 0:
486
+ result.insert(last_human_idx, memory_msg)
487
+ else:
488
+ result.insert(0, memory_msg)
489
+
490
+ if result and isinstance(result[0], SystemMessage):
491
+ existing_content = result[0].content
492
+ if isinstance(existing_content, list):
493
+ new_content = list(existing_content) + [
494
+ {"type": "text", "text": f"\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}"}
495
+ ]
496
+ else:
497
+ text_str = str(existing_content or "")
498
+ new_content = f"{text_str}\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}".strip()
499
+ if hasattr(result[0], "model_copy"):
500
+ result[0] = result[0].model_copy(update={"content": new_content})
501
+ else:
502
+ result[0] = SystemMessage(
503
+ content=new_content,
504
+ additional_kwargs=getattr(result[0], "additional_kwargs", {}),
505
+ id=getattr(result[0], "id", None),
506
+ name=getattr(result[0], "name", None),
507
+ )
508
+ else:
509
+ result.insert(0, SystemMessage(content=_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION))
510
+ return result
511
+
427
512
  async def _inject_memories(messages: List[BaseMessage]) -> List[BaseMessage]:
428
513
  """Recall memories and inject them without granting system priority."""
514
+ turn_key = _user_turn_key(messages)
515
+ # Tool steps reuse this turn, including a recall that matched nothing.
516
+ # A user message always recalls again, so a later fact is not stuck
517
+ # behind the first result for the same text.
518
+ if (
519
+ turn_key
520
+ and not _is_last_message_user(messages)
521
+ and turn_key in recall_cache
522
+ ):
523
+ cached = recall_cache[turn_key]
524
+ if cached:
525
+ return _with_memory(messages, cached)
526
+ return messages
527
+ if not turn_key and not _is_last_message_user(messages):
528
+ return messages
529
+
429
530
  user_text = _find_last_user_message(messages)
430
531
  if not user_text:
431
532
  return messages
@@ -437,54 +538,22 @@ def with_memwal_langchain(
437
538
  if (1 - m.distance) >= min_relevance
438
539
  ]
439
540
  if not relevant:
541
+ if turn_key:
542
+ _remember_recall(recall_cache, turn_key, "")
440
543
  return messages
441
544
 
442
545
  memory_context = _format_memories(relevant)
546
+ if turn_key:
547
+ _remember_recall(recall_cache, turn_key, memory_context)
443
548
  log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
444
-
445
- # The fixed trust rule is privileged; recalled bytes are not.
446
- result = list(messages)
447
- last_human_idx = -1
448
- for i in range(len(result) - 1, -1, -1):
449
- if isinstance(result[i], HumanMessage):
450
- last_human_idx = i
451
- break
452
-
453
- memory_msg = HumanMessage(content=memory_context)
454
- if last_human_idx > 0:
455
- result.insert(last_human_idx, memory_msg)
456
- else:
457
- result.insert(0, memory_msg)
458
-
459
- if result and isinstance(result[0], SystemMessage):
460
- existing_content = result[0].content
461
- if isinstance(existing_content, list):
462
- new_content = list(existing_content) + [
463
- {"type": "text", "text": f"\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}"}
464
- ]
465
- else:
466
- text_str = str(existing_content or "")
467
- new_content = f"{text_str}\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}".strip()
468
- if hasattr(result[0], "model_copy"):
469
- result[0] = result[0].model_copy(update={"content": new_content})
470
- else:
471
- result[0] = SystemMessage(
472
- content=new_content,
473
- additional_kwargs=getattr(result[0], "additional_kwargs", {}),
474
- id=getattr(result[0], "id", None),
475
- name=getattr(result[0], "name", None),
476
- )
477
- else:
478
- result.insert(0, SystemMessage(content=_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION))
479
-
480
- return result
549
+ return _with_memory(messages, memory_context)
481
550
  except Exception as e:
482
551
  log(f"[Walrus Memory] Memory search failed: {e}")
483
552
  return messages
484
553
 
485
554
  async def _post_analyze(messages: List[BaseMessage]) -> None:
486
555
  """Analyze user message for new facts."""
487
- if not auto_save:
556
+ if not auto_save or not _is_last_message_user(messages):
488
557
  return
489
558
  user_text = _find_last_user_message(messages)
490
559
  if user_text:
@@ -495,39 +564,22 @@ def with_memwal_langchain(
495
564
  pending.note_error(e)
496
565
 
497
566
  async def patched_agenerate(
498
- messages: List[List[BaseMessage]], *args: Any, **kwargs: Any
567
+ messages: List[BaseMessage], *args: Any, **kwargs: Any
499
568
  ) -> ChatResult:
500
- enriched = []
501
- for msg_list in messages:
502
- enriched.append(await _inject_memories(msg_list))
503
-
569
+ enriched = await _inject_memories(messages)
504
570
  result = await original_agenerate(enriched, *args, **kwargs)
505
-
506
- for msg_list in messages:
507
- _fire_and_forget(_post_analyze(msg_list), pending)
508
-
571
+ _fire_and_forget(_post_analyze(messages), pending)
509
572
  return result
510
573
 
511
574
  def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
512
- # Keep httpx clients bound to the short-lived loop that uses them.
513
575
  return _run_blocking(lambda: _with_fresh_http_client(memwal, coro_factory()))
514
576
 
515
577
  def patched_generate(
516
- messages: List[List[BaseMessage]], *args: Any, **kwargs: Any
578
+ messages: List[BaseMessage], *args: Any, **kwargs: Any
517
579
  ) -> ChatResult:
518
- # Inject via _run_blocking so recall still runs when a loop is already
519
- # running (notebooks, async hosts) — the same helper the sync OpenAI
520
- # wrapper uses. Passing the messages through untouched there made
521
- # Walrus Memory look connected while recall silently never ran.
522
- enriched = []
523
- for msg_list in messages:
524
- enriched.append(_run_memwal(lambda: _inject_memories(msg_list)))
525
-
580
+ enriched = _run_memwal(lambda: _inject_memories(messages))
526
581
  result = original_generate(enriched, *args, **kwargs)
527
-
528
- for msg_list in messages:
529
- _fire_and_forget(_post_analyze(msg_list), pending)
530
-
582
+ _fire_and_forget(_post_analyze(messages), pending)
531
583
  return result
532
584
 
533
585
  # Monkey-patch the LLM instance
@@ -626,44 +678,65 @@ def _wrap_async_openai(
626
678
  ) -> None:
627
679
  """Wrap an async OpenAI client's chat.completions.create."""
628
680
  original_create = client.chat.completions.create
629
-
681
+ recall_cache: dict[str, str] = {}
630
682
  async def patched_create(*args: Any, **kwargs: Any) -> Any:
631
683
  messages = kwargs.get("messages") or (args[0] if args else None)
632
684
  if messages is None:
633
685
  return await original_create(*args, **kwargs)
634
686
 
635
- # Inject memories
636
- user_text = _find_last_user_message(messages)
637
- if user_text:
638
- try:
639
- recall_result = await memwal.recall(user_text, max_memories, namespace)
640
- relevant = [
641
- m for m in recall_result.results
642
- if (1 - m.distance) >= min_relevance
643
- ]
644
- if relevant:
645
- memory_context = _format_memories(relevant)
646
- log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
647
- messages = _inject_openai_memory(list(messages), memory_context)
648
- if "messages" in kwargs:
649
- kwargs["messages"] = messages
650
- elif args:
651
- args = (messages,) + args[1:]
652
- except Exception as e:
653
- log(f"[Walrus Memory] Memory search failed: {e}")
687
+ # A tool step reuses this turn's recall, including an empty one.
688
+ # A user message always recalls again.
689
+ turn_key = _user_turn_key(messages)
690
+ if (
691
+ turn_key
692
+ and not _is_last_message_user(messages)
693
+ and turn_key in recall_cache
694
+ ):
695
+ cached = recall_cache[turn_key]
696
+ if cached:
697
+ messages = _inject_openai_memory(list(messages), cached)
698
+ if "messages" in kwargs:
699
+ kwargs["messages"] = messages
700
+ elif args:
701
+ args = (messages,) + args[1:]
702
+ elif turn_key:
703
+ user_text = _find_last_user_message(messages)
704
+ if user_text:
705
+ try:
706
+ recall_result = await memwal.recall(user_text, max_memories, namespace)
707
+ relevant = [
708
+ m for m in recall_result.results
709
+ if (1 - m.distance) >= min_relevance
710
+ ]
711
+ if not relevant and turn_key:
712
+ _remember_recall(recall_cache, turn_key, "")
713
+ if relevant:
714
+ memory_context = _format_memories(relevant)
715
+ if turn_key:
716
+ _remember_recall(recall_cache, turn_key, memory_context)
717
+ log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
718
+ messages = _inject_openai_memory(list(messages), memory_context)
719
+ if "messages" in kwargs:
720
+ kwargs["messages"] = messages
721
+ elif args:
722
+ args = (messages,) + args[1:]
723
+ except Exception as e:
724
+ log(f"[Walrus Memory] Memory search failed: {e}")
654
725
 
655
726
  result = await original_create(*args, **kwargs)
656
727
 
657
728
  # Fire-and-forget analyze
658
- if auto_save and user_text:
659
- async def _analyze() -> None:
660
- try:
661
- await memwal.analyze(user_text, namespace)
662
- except Exception as e:
663
- log(f"[Walrus Memory] Auto-save failed: {e}")
664
- pending.note_error(e)
665
-
666
- _fire_and_forget(_analyze(), pending)
729
+ if auto_save and _is_last_message_user(messages):
730
+ user_text = _find_last_user_message(messages)
731
+ 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)
667
740
 
668
741
  return result
669
742
 
@@ -682,7 +755,7 @@ def _wrap_sync_openai(
682
755
  ) -> None:
683
756
  """Wrap a sync OpenAI client's chat.completions.create."""
684
757
  original_create = client.chat.completions.create
685
-
758
+ recall_cache: dict[str, str] = {}
686
759
  def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
687
760
  # Keep httpx clients bound to the short-lived loop that uses them.
688
761
  return _run_blocking(lambda: _with_fresh_http_client(memwal, coro_factory()))
@@ -692,40 +765,60 @@ def _wrap_sync_openai(
692
765
  if messages is None:
693
766
  return original_create(*args, **kwargs)
694
767
 
695
- # Inject memories (sync)
696
- user_text = _find_last_user_message(messages)
697
- if user_text:
698
- try:
699
- recall_result = _run_memwal(
700
- lambda: memwal.recall(user_text, max_memories, namespace)
701
- )
702
- relevant = [
703
- m for m in recall_result.results
704
- if (1 - m.distance) >= min_relevance
705
- ]
706
- if relevant:
707
- memory_context = _format_memories(relevant)
708
- log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
709
- messages = _inject_openai_memory(list(messages), memory_context)
710
- if "messages" in kwargs:
711
- kwargs["messages"] = messages
712
- elif args:
713
- args = (messages,) + args[1:]
714
- except Exception as e:
715
- log(f"[Walrus Memory] Memory search failed: {e}")
768
+ # A user message always recalls again.
769
+ turn_key = _user_turn_key(messages)
770
+ if (
771
+ turn_key
772
+ and not _is_last_message_user(messages)
773
+ and turn_key in recall_cache
774
+ ):
775
+ cached = recall_cache[turn_key]
776
+ if cached:
777
+ messages = _inject_openai_memory(list(messages), cached)
778
+ if "messages" in kwargs:
779
+ kwargs["messages"] = messages
780
+ elif args:
781
+ args = (messages,) + args[1:]
782
+ elif turn_key:
783
+ user_text = _find_last_user_message(messages)
784
+ if user_text:
785
+ try:
786
+ recall_result = _run_memwal(
787
+ lambda: memwal.recall(user_text, max_memories, namespace)
788
+ )
789
+ relevant = [
790
+ m for m in recall_result.results
791
+ if (1 - m.distance) >= min_relevance
792
+ ]
793
+ if not relevant and turn_key:
794
+ _remember_recall(recall_cache, turn_key, "")
795
+ if relevant:
796
+ memory_context = _format_memories(relevant)
797
+ if turn_key:
798
+ _remember_recall(recall_cache, turn_key, memory_context)
799
+ log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
800
+ messages = _inject_openai_memory(list(messages), memory_context)
801
+ if "messages" in kwargs:
802
+ kwargs["messages"] = messages
803
+ elif args:
804
+ args = (messages,) + args[1:]
805
+ except Exception as e:
806
+ log(f"[Walrus Memory] Memory search failed: {e}")
716
807
 
717
808
  result = original_create(*args, **kwargs)
718
809
 
719
810
  # Fire-and-forget analyze
720
- if auto_save and user_text:
721
- def _analyze() -> None:
722
- try:
723
- _run_memwal(lambda: memwal.analyze(user_text, namespace))
724
- except Exception as e:
725
- log(f"[Walrus Memory] Auto-save failed: {e}")
726
- pending.note_error(e)
727
-
728
- pending.spawn_thread(_analyze)
811
+ if auto_save and _is_last_message_user(messages):
812
+ user_text = _find_last_user_message(messages)
813
+ if user_text:
814
+ def _analyze() -> None:
815
+ try:
816
+ _run_memwal(lambda: memwal.analyze(user_text, namespace))
817
+ except Exception as e:
818
+ log(f"[Walrus Memory] Auto-save failed: {e}")
819
+ pending.note_error(e)
820
+
821
+ pending.spawn_thread(_analyze)
729
822
 
730
823
  return result
731
824
 
@@ -261,16 +261,28 @@ class MemWalMock:
261
261
  namespace: Optional[str] = None,
262
262
  max_distance: Optional[float] = None,
263
263
  ) -> RecallResult:
264
+ """Token-overlap recall over the in-memory store.
265
+
266
+ ``RecallParams.sort`` is honored: ``"recent"`` keeps the semantic top
267
+ ``max(min(limit * 5, 50), limit)`` and orders that window newest first,
268
+ like the relayer. Otherwise results are ordered by token-overlap
269
+ distance. ``RecallParams.scoring_weights`` is accepted but ignored and
270
+ does not re-rank; ``distance`` is always token overlap.
271
+ """
264
272
  if isinstance(query, RecallParams):
265
273
  limit = query.limit
266
274
  namespace = query.namespace
267
275
  max_distance = query.max_distance
276
+ sort = query.sort
268
277
  query_text = query.query
269
278
  else:
279
+ sort = None
270
280
  query_text = query
271
281
  _validate_text(query_text, "query")
272
282
  if not isinstance(limit, int) or limit < 0:
273
283
  raise ValueError("limit must be a non-negative integer")
284
+ if sort not in (None, "relevance", "recent"):
285
+ raise ValueError("sort must be 'relevance' or 'recent'")
274
286
  resolved_namespace = namespace or self._namespace
275
287
  query_tokens = _tokens(query_text)
276
288
  ranked = [
@@ -279,6 +291,11 @@ class MemWalMock:
279
291
  if memory.namespace == resolved_namespace
280
292
  ]
281
293
  ranked.sort(key=lambda item: (item[1], item[0].sequence))
294
+ if sort == "recent":
295
+ # Same window as the TypeScript mock and RecallSort::candidate_limit.
296
+ # A newer memory outside that semantic window must not win.
297
+ window = max(min(limit * 5, 50), limit)
298
+ ranked = sorted(ranked[:window], key=lambda item: (-item[0].sequence, item[1]))
282
299
  page = ranked[:limit]
283
300
  if max_distance is not None:
284
301
  page = [item for item in page if item[1] < max_distance]
@@ -108,6 +108,8 @@ class RecallParams:
108
108
  limit: int = 10
109
109
  namespace: Optional[str] = None
110
110
  max_distance: Optional[float] = None
111
+ scoring_weights: Optional["ScoringWeights"] = None
112
+ sort: Optional[str] = None
111
113
 
112
114
 
113
115
  @dataclass
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev13"
7
+ version = "0.1.11.dev14"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -331,7 +331,7 @@ class TestWithMemWalLangChain:
331
331
  captured: list = []
332
332
 
333
333
  async def _capture_agenerate(messages_batch, *a, **kw):
334
- captured.extend(messages_batch)
334
+ captured.append(messages_batch)
335
335
  return ChatResult(
336
336
  generations=[ChatGeneration(message=AIMessage(content="ok"))]
337
337
  )
@@ -348,7 +348,7 @@ class TestWithMemWalLangChain:
348
348
  smart_llm = with_memwal_langchain(
349
349
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
350
350
  )
351
- await smart_llm._agenerate([[HumanMessage("What do I drink?")]])
351
+ await smart_llm._agenerate([HumanMessage("What do I drink?")])
352
352
 
353
353
  assert len(captured) == 1
354
354
  injected = captured[0]
@@ -372,7 +372,7 @@ class TestWithMemWalLangChain:
372
372
  captured: list = []
373
373
 
374
374
  async def _capture_agenerate(messages_batch, *a, **kw):
375
- captured.extend(messages_batch)
375
+ captured.append(messages_batch)
376
376
  return ChatResult(
377
377
  generations=[ChatGeneration(message=AIMessage(content="ok"))]
378
378
  )
@@ -395,7 +395,7 @@ class TestWithMemWalLangChain:
395
395
  AIMessage(content="Noted!"),
396
396
  HumanMessage(content="What do I like?"),
397
397
  ]
398
- await smart_llm._agenerate([messages])
398
+ await smart_llm._agenerate(messages)
399
399
 
400
400
  assert len(captured) == 1
401
401
  injected = captured[0]
@@ -424,7 +424,7 @@ class TestWithMemWalLangChain:
424
424
  captured: list = []
425
425
 
426
426
  async def _capture_agenerate(messages_batch, *a, **kw):
427
- captured.extend(messages_batch)
427
+ captured.append(messages_batch)
428
428
  return ChatResult(
429
429
  generations=[ChatGeneration(message=AIMessage(content="ok"))]
430
430
  )
@@ -445,7 +445,7 @@ class TestWithMemWalLangChain:
445
445
  {"type": "text", "text": "Base instructions", "cache_control": {"type": "ephemeral"}}
446
446
  ]
447
447
  sys_msg = SystemMessage(content=blocks, id="sys-123", additional_kwargs={"source": "test"})
448
- await smart_llm._agenerate([[sys_msg, HumanMessage("What do I like?")]])
448
+ await smart_llm._agenerate([sys_msg, HumanMessage("What do I like?")])
449
449
 
450
450
  assert len(captured) == 1
451
451
  injected = captured[0]
@@ -468,7 +468,7 @@ class TestWithMemWalLangChain:
468
468
  captured: list = []
469
469
 
470
470
  async def _capture_agenerate(messages_batch, *a, **kw):
471
- captured.extend(messages_batch)
471
+ captured.append(messages_batch)
472
472
  return ChatResult(
473
473
  generations=[ChatGeneration(message=AIMessage(content="ok"))]
474
474
  )
@@ -481,7 +481,7 @@ class TestWithMemWalLangChain:
481
481
  smart_llm = with_memwal_langchain(
482
482
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
483
483
  )
484
- await smart_llm._agenerate([[HumanMessage("hello")]])
484
+ await smart_llm._agenerate([HumanMessage("hello")])
485
485
 
486
486
  injected = captured[0]
487
487
  system_msgs = [m for m in injected if isinstance(m, SystemMessage)]
@@ -498,7 +498,7 @@ class TestWithMemWalLangChain:
498
498
  captured: list = []
499
499
 
500
500
  async def _capture_agenerate(messages_batch, *a, **kw):
501
- captured.extend(messages_batch)
501
+ captured.append(messages_batch)
502
502
  return ChatResult(
503
503
  generations=[ChatGeneration(message=AIMessage(content="ok"))]
504
504
  )
@@ -517,7 +517,7 @@ class TestWithMemWalLangChain:
517
517
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER,
518
518
  min_relevance=0.3, auto_save=False
519
519
  )
520
- await smart_llm._agenerate([[HumanMessage("hello")]])
520
+ await smart_llm._agenerate([HumanMessage("hello")])
521
521
 
522
522
  injected = captured[0]
523
523
  system_msgs = [m for m in injected if isinstance(m, SystemMessage)]
@@ -538,7 +538,7 @@ class TestWithMemWalLangChain:
538
538
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
539
539
  )
540
540
  # Should NOT raise — recall failure is swallowed, LLM still called
541
- await smart_llm._agenerate([[HumanMessage("will this work?")]])
541
+ await smart_llm._agenerate([HumanMessage("will this work?")])
542
542
 
543
543
  @respx.mock
544
544
  async def test_auto_save_triggers_analyze(self) -> None:
@@ -554,7 +554,7 @@ class TestWithMemWalLangChain:
554
554
  smart_llm = with_memwal_langchain(
555
555
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
556
556
  )
557
- await smart_llm._agenerate([[HumanMessage("store this")]])
557
+ await smart_llm._agenerate([HumanMessage("store this")])
558
558
 
559
559
  # Give fire-and-forget task time to run
560
560
  await asyncio.sleep(0.05)
@@ -572,7 +572,7 @@ class TestWithMemWalLangChain:
572
572
  smart_llm = with_memwal_langchain(
573
573
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
574
574
  )
575
- await smart_llm._agenerate([[SystemMessage("only system")]])
575
+ await smart_llm._agenerate([SystemMessage("only system")])
576
576
  assert not recall_route.called
577
577
 
578
578
  def _capturing_generate(self, captured: list):
@@ -581,7 +581,7 @@ class TestWithMemWalLangChain:
581
581
  from langchain_core.outputs import ChatGeneration, ChatResult
582
582
 
583
583
  def _generate(messages_batch, *a, **kw):
584
- captured.extend(messages_batch)
584
+ captured.append(messages_batch)
585
585
  return ChatResult(
586
586
  generations=[ChatGeneration(message=AIMessage(content="ok"))]
587
587
  )
@@ -607,7 +607,7 @@ class TestWithMemWalLangChain:
607
607
  smart_llm = with_memwal_langchain(
608
608
  llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
609
609
  )
610
- smart_llm._generate([[HumanMessage("What do I drink?")]])
610
+ smart_llm._generate([HumanMessage("What do I drink?")])
611
611
 
612
612
  assert recall_route.called
613
613
  assert any("User loves coffee" in m.content for m in captured[0])
@@ -641,7 +641,7 @@ class TestWithMemWalLangChain:
641
641
  # An async test body is itself a running loop -- the exact condition
642
642
  # that used to disable injection.
643
643
  assert asyncio.get_running_loop().is_running()
644
- smart_llm._generate([[HumanMessage("What do I drink?")]])
644
+ smart_llm._generate([HumanMessage("What do I drink?")])
645
645
 
646
646
  assert recall_route.called, "recall was skipped inside a running loop"
647
647
  assert len(captured) == 1
@@ -671,6 +671,109 @@ class TestWithMemWalLangChain:
671
671
  assert callable(smart_llm.memwal_flush)
672
672
  assert callable(smart_llm.memwal_flush_sync)
673
673
 
674
+ @respx.mock
675
+ async def test_tool_steps_do_not_retrigger_recall_or_analyze(self) -> None:
676
+ """LangChain tool messages do not retrigger recall or auto-save."""
677
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
678
+ from langchain_core.outputs import ChatGeneration, ChatResult
679
+ _mock_seal_session_prereqs()
680
+ llm = self._make_llm()
681
+ captured: list = []
682
+
683
+ async def _capture(batch, *args, **kwargs):
684
+ captured.append(batch)
685
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
686
+
687
+ llm._agenerate = _capture
688
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
689
+ {"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
690
+ ]))
691
+ analyze_route = respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
692
+
693
+ smart = with_memwal_langchain(
694
+ llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
695
+ )
696
+
697
+ messages = [HumanMessage(content="What is the weather?")]
698
+ await smart._agenerate(messages)
699
+ await smart.memwal_flush()
700
+
701
+ assert recall_route.call_count == 1
702
+ assert analyze_route.call_count == 1
703
+
704
+ messages.extend([
705
+ AIMessage(content="", tool_calls=[{"name": "weather", "args": {}, "id": "1"}]),
706
+ ToolMessage(content="Sunny 20C", tool_call_id="1"),
707
+ ])
708
+ await smart._agenerate(messages)
709
+ await smart.memwal_flush()
710
+
711
+ assert recall_route.call_count == 1, "recall must NOT be called again on tool step"
712
+ assert analyze_route.call_count == 1, "analyze must NOT be called again on tool step"
713
+ assert any(
714
+ "User lives in Lisbon" in getattr(message, "content", "")
715
+ for message in captured[-1]
716
+ )
717
+ # Step 3: Next user turn starts
718
+ messages.extend([
719
+ AIMessage(content="It is sunny!"),
720
+ HumanMessage(content="Thanks!"),
721
+ ])
722
+ await smart._agenerate(messages)
723
+ await smart.memwal_flush()
724
+
725
+ assert recall_route.call_count == 2
726
+ assert analyze_route.call_count == 2
727
+
728
+ @respx.mock
729
+ async def test_same_user_text_recalls_again(self) -> None:
730
+ """A later user turn with the same text must not reuse the first recall."""
731
+ from langchain_core.messages import AIMessage, HumanMessage
732
+ from langchain_core.outputs import ChatGeneration, ChatResult
733
+ _mock_seal_session_prereqs()
734
+ llm = self._make_llm()
735
+
736
+ async def _ok(batch, *args, **kwargs):
737
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
738
+
739
+ llm._agenerate = _ok
740
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
741
+ {"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
742
+ ]))
743
+ smart = with_memwal_langchain(
744
+ llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
745
+ )
746
+ await smart._agenerate([HumanMessage(content="what's my name?")])
747
+ await smart._agenerate([HumanMessage(content="what's my name?")])
748
+ assert recall_route.call_count == 2
749
+
750
+ @respx.mock
751
+ async def test_empty_recall_is_reused_on_the_tool_step(self) -> None:
752
+ """A recall with no relevant hit is cached, so the tool step does not call again."""
753
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
754
+ from langchain_core.outputs import ChatGeneration, ChatResult
755
+ _mock_seal_session_prereqs()
756
+ llm = self._make_llm()
757
+
758
+ async def _ok(batch, *args, **kwargs):
759
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
760
+
761
+ llm._agenerate = _ok
762
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
763
+ {"blob_id": "b1", "text": "unrelated", "distance": 0.99}
764
+ ]))
765
+ smart = with_memwal_langchain(
766
+ llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
767
+ )
768
+ messages = [HumanMessage(content="What is the weather?")]
769
+ await smart._agenerate(messages)
770
+ messages.extend([
771
+ AIMessage(content="", tool_calls=[{"name": "weather", "args": {}, "id": "1"}]),
772
+ ToolMessage(content="Sunny", tool_call_id="1"),
773
+ ])
774
+ await smart._agenerate(messages)
775
+ assert recall_route.call_count == 1
776
+
674
777
 
675
778
  # ============================================================
676
779
  # OpenAI middleware tests
@@ -882,6 +985,112 @@ class TestWithMemWalOpenAI:
882
985
  system_msgs = [m for m in captured if isinstance(m, dict) and m.get("role") == "system"]
883
986
  assert len(system_msgs) == 0
884
987
 
988
+ @respx.mock
989
+ async def test_tool_execution_steps_do_not_retrigger_recall_or_analyze(self) -> None:
990
+ """Tool response messages (role: tool) do not retrigger recall or auto-save."""
991
+ _mock_seal_session_prereqs()
992
+ client = self._make_async_client()
993
+ captured: list = []
994
+
995
+ async def _capture(*args, **kwargs):
996
+ captured.append(kwargs.get("messages"))
997
+ return MagicMock()
998
+
999
+ client.chat.completions.create = _capture
1000
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
1001
+ {"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
1002
+ ]))
1003
+ analyze_route = respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
1004
+
1005
+ smart = with_memwal_openai(
1006
+ client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
1007
+ )
1008
+
1009
+ messages = [{"role": "user", "content": "What is the weather in Lisbon?"}]
1010
+ await smart.chat.completions.create(model="gpt-4o", messages=messages)
1011
+ await smart.memwal_flush()
1012
+
1013
+ assert recall_route.call_count == 1
1014
+ assert analyze_route.call_count == 1
1015
+
1016
+ messages.extend([
1017
+ {
1018
+ "role": "assistant",
1019
+ "tool_calls": [{"id": "call_1", "function": {"name": "get_weather"}}],
1020
+ },
1021
+ {"role": "tool", "tool_call_id": "call_1", "content": "22C sunny"},
1022
+ ])
1023
+ await smart.chat.completions.create(model="gpt-4o", messages=messages)
1024
+ await smart.memwal_flush()
1025
+
1026
+ assert recall_route.call_count == 1, "recall must NOT be called again on tool step"
1027
+ assert analyze_route.call_count == 1, "analyze must NOT be called again on tool step"
1028
+ assert any(
1029
+ isinstance(message, dict) and "User lives in Lisbon" in str(message.get("content", ""))
1030
+ for message in captured[-1]
1031
+ )
1032
+ # Step 3: Next user turn starts
1033
+ messages.extend([
1034
+ {"role": "assistant", "content": "It is 22C and sunny in Lisbon."},
1035
+ {"role": "user", "content": "And in Porto?"},
1036
+ ])
1037
+ await smart.chat.completions.create(model="gpt-4o", messages=messages)
1038
+ await smart.memwal_flush()
1039
+
1040
+ assert recall_route.call_count == 2
1041
+ assert analyze_route.call_count == 2
1042
+
1043
+ @respx.mock
1044
+ async def test_same_user_text_recalls_again(self) -> None:
1045
+ _mock_seal_session_prereqs()
1046
+ client = self._make_async_client()
1047
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
1048
+ {"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
1049
+ ]))
1050
+ smart = with_memwal_openai(
1051
+ client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
1052
+ )
1053
+ await smart.chat.completions.create(
1054
+ model="gpt-4o", messages=[{"role": "user", "content": "what's my name?"}]
1055
+ )
1056
+ await smart.chat.completions.create(
1057
+ model="gpt-4o", messages=[{"role": "user", "content": "what's my name?"}]
1058
+ )
1059
+ assert recall_route.call_count == 2
1060
+
1061
+ @respx.mock
1062
+ async def test_empty_recall_is_reused_on_the_tool_step(self) -> None:
1063
+ _mock_seal_session_prereqs()
1064
+ client = self._make_async_client()
1065
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
1066
+ {"blob_id": "b1", "text": "unrelated", "distance": 0.99}
1067
+ ]))
1068
+ smart = with_memwal_openai(
1069
+ client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
1070
+ )
1071
+ messages = [{"role": "user", "content": "hello"}]
1072
+ await smart.chat.completions.create(model="gpt-4o", messages=messages)
1073
+ messages = messages + [{"role": "tool", "tool_call_id": "1", "content": "ok"}]
1074
+ await smart.chat.completions.create(model="gpt-4o", messages=messages)
1075
+ assert recall_route.call_count == 1
1076
+
1077
+ @respx.mock
1078
+ def test_sync_empty_recall_is_reused_on_the_tool_step(self) -> None:
1079
+ _mock_seal_session_prereqs()
1080
+ client = self._make_sync_client()
1081
+ recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
1082
+ {"blob_id": "b1", "text": "unrelated", "distance": 0.99}
1083
+ ]))
1084
+ smart = with_memwal_openai(
1085
+ client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
1086
+ )
1087
+ messages = [{"role": "user", "content": "hello"}]
1088
+ smart.chat.completions.create(model="gpt-4o", messages=messages)
1089
+ messages = messages + [{"role": "tool", "tool_call_id": "1", "content": "ok"}]
1090
+ smart.chat.completions.create(model="gpt-4o", messages=messages)
1091
+ assert recall_route.call_count == 1
1092
+
1093
+
885
1094
  @respx.mock
886
1095
  def test_sync_client_wraps_create(self) -> None:
887
1096
  """Sync OpenAI client wrapper is applied correctly."""
@@ -46,6 +46,37 @@ async def test_mock_remember_and_recall_are_deterministic_and_offline(monkeypatc
46
46
  assert recalled.results[1].created_at == "2026-01-01T00:00:02.000Z"
47
47
 
48
48
 
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_mock_recall_sort_recent_is_not_relevance_order():
52
+ mock = MemWalMock.create(namespace="user-a")
53
+ await mock.remember_and_wait("I prefer coffee in the morning")
54
+ await mock.remember_and_wait("I drink tea at night")
55
+
56
+ recent = await mock.recall(RecallParams(query="I", sort="recent"))
57
+ assert [memory.text for memory in recent.results] == [
58
+ "I drink tea at night",
59
+ "I prefer coffee in the morning",
60
+ ]
61
+
62
+ with pytest.raises(ValueError, match="sort"):
63
+ await mock.recall(RecallParams(query="I", sort="newest"))
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_mock_recall_sort_recent_stays_inside_the_semantic_window():
68
+ mock = MemWalMock.create(namespace="user-a")
69
+ for i in range(6):
70
+ await mock.remember_and_wait(f"same fact {i}")
71
+ recent = await mock.recall(RecallParams(query="same fact", limit=1, sort="recent"))
72
+ assert recent.results[0].text == "same fact 4"
73
+
74
+ wide = MemWalMock.create(namespace="user-a")
75
+ for i in range(51):
76
+ await wide.remember_and_wait(f"same fact {i}")
77
+ first = await wide.recall(RecallParams(query="same fact", limit=11, sort="recent"))
78
+ assert first.results[0].text == "same fact 49"
79
+
49
80
  @pytest.mark.asyncio
50
81
  async def test_mock_isolates_namespaces_and_honors_max_distance():
51
82
  mock = MemWalMock.create()
@@ -0,0 +1,42 @@
1
+ import pytest
2
+
3
+ from memwal import MemWal, RecallParams, ScoringWeights
4
+
5
+
6
+ class CaptureMemWal(MemWal):
7
+ def __init__(self, *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+ self.captured = []
10
+
11
+ async def _ensure_compatible_relayer(self):
12
+ return {
13
+ "apiVersion": "1.0.0",
14
+ "relayerVersion": "capture",
15
+ "minSupportedSdk": {"python": "0.0.0"},
16
+ }
17
+
18
+ async def _signed_request(
19
+ self, method, path, body, accepted_statuses=(200,), include_seal_session=True,
20
+ ):
21
+ self.captured.append((method, path, body))
22
+ return {"results": [], "total": 0}
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ async def test_recall_forwards_scoring_weights_and_sort():
27
+ client = CaptureMemWal.create(
28
+ key="11" * 32,
29
+ account_id="0x" + "ab" * 32,
30
+ server_url="https://relayer.example",
31
+ )
32
+ await client.recall(RecallParams(
33
+ query="food allergies",
34
+ scoring_weights=ScoringWeights(recency=1.0),
35
+ sort="recent",
36
+ ))
37
+ method, path, body = client.captured[0]
38
+ assert method == "POST"
39
+ assert path == "/api/recall"
40
+ assert body["scoring_weights"] == {"recency": 1.0}
41
+ assert body["sort"] == "recent"
42
+ await client.close()
File without changes