memwal 0.1.6.dev0__tar.gz → 0.1.6.dev2__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 (27) hide show
  1. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/PKG-INFO +19 -2
  2. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/README.md +17 -0
  3. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/__init__.py +5 -1
  4. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/client.py +56 -10
  5. memwal-0.1.6.dev2/memwal/mock.py +585 -0
  6. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/pyproject.toml +1 -1
  7. memwal-0.1.6.dev2/tests/test_mock.py +162 -0
  8. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/.gitignore +0 -0
  9. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/CHANGELOG.md +0 -0
  10. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/.env.example +0 -0
  11. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/.gitignore +0 -0
  12. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/async_remember_demo.py +0 -0
  13. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/interactive_demo.py +0 -0
  14. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/verify_credentials.py +0 -0
  15. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/compatibility.py +0 -0
  16. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/middleware.py +0 -0
  17. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/types.py +0 -0
  18. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/utils.py +0 -0
  19. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  20. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/run_tests.py +0 -0
  21. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/__init__.py +0 -0
  22. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_auth_rejected_message.py +0 -0
  23. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_client.py +0 -0
  24. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_env_presets.py +0 -0
  25. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_integration.py +0 -0
  26. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_middleware.py +0 -0
  27. {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_signing.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.6.dev0
3
+ Version: 0.1.6.dev2
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
@@ -124,6 +124,23 @@ matches = client.recall(RecallParams(query="food allergies"))
124
124
  client.close()
125
125
  ```
126
126
 
127
+ ### Offline tests and CI
128
+
129
+ `MemWalMock` and `MemWalMockSync` implement the common memory API in process. They require no credentials, relayer, chain, or paid storage and use deterministic token-overlap ranking.
130
+
131
+ ```python
132
+ from memwal import MemWalMock, RecallParams
133
+
134
+ async def test_memory_flow():
135
+ memwal = MemWalMock.create(namespace="test-user")
136
+ await memwal.remember_and_wait("The user prefers dark mode")
137
+
138
+ result = await memwal.recall(RecallParams(query="display preference"))
139
+ assert "dark mode" in result.results[0].text
140
+ ```
141
+
142
+ The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, `forget(blob_id)`, and `clear(namespace)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
143
+
127
144
  ### Context Manager
128
145
 
129
146
  ```python
@@ -85,6 +85,23 @@ matches = client.recall(RecallParams(query="food allergies"))
85
85
  client.close()
86
86
  ```
87
87
 
88
+ ### Offline tests and CI
89
+
90
+ `MemWalMock` and `MemWalMockSync` implement the common memory API in process. They require no credentials, relayer, chain, or paid storage and use deterministic token-overlap ranking.
91
+
92
+ ```python
93
+ from memwal import MemWalMock, RecallParams
94
+
95
+ async def test_memory_flow():
96
+ memwal = MemWalMock.create(namespace="test-user")
97
+ await memwal.remember_and_wait("The user prefers dark mode")
98
+
99
+ result = await memwal.recall(RecallParams(query="display preference"))
100
+ assert "dark mode" in result.results[0].text
101
+ ```
102
+
103
+ The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, `forget(blob_id)`, and `clear(namespace)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
104
+
88
105
  ### Context Manager
89
106
 
90
107
  ```python
@@ -32,6 +32,7 @@ from .client import (
32
32
  MemWalSync,
33
33
  )
34
34
  from .middleware import with_memwal_langchain, with_memwal_openai
35
+ from .mock import MemWalMock, MemWalMockSeed, MemWalMockSync
35
36
  from .types import (
36
37
  ENV_PRESETS,
37
38
  AnalyzedFact,
@@ -72,6 +73,9 @@ __all__ = [
72
73
  # Core client
73
74
  "MemWal",
74
75
  "MemWalSync",
76
+ "MemWalMock",
77
+ "MemWalMockSync",
78
+ "MemWalMockSeed",
75
79
  "MemWalError",
76
80
  "MemWalCompatibilityError",
77
81
  "MemWalRememberJobFailed",
@@ -116,4 +120,4 @@ __all__ = [
116
120
  "RecallManualResult",
117
121
  ]
118
122
 
119
- __version__ = "0.1.6.dev0"
123
+ __version__ = "0.1.6.dev2"
@@ -30,6 +30,7 @@ import base64
30
30
  import json
31
31
  import random
32
32
  import time
33
+ import uuid
33
34
  from datetime import datetime, timezone
34
35
  from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
35
36
 
@@ -223,6 +224,9 @@ class MemWal:
223
224
  self._session_build_task: Optional[asyncio.Task[str]] = None
224
225
  self._relayer_version_metadata: Optional[Dict[str, Any]] = None
225
226
  self._compatibility_lock: Optional[asyncio.Lock] = None
227
+ # Preserve a generated key across an ambiguous transport failure. A
228
+ # subsequent identical call then collapses onto the accepted paid job.
229
+ self._pending_remember_keys: Dict[str, str] = {}
226
230
 
227
231
  @classmethod
228
232
  def create(
@@ -281,7 +285,10 @@ class MemWal:
281
285
  # ============================================================
282
286
 
283
287
  async def remember(
284
- self, text: str, namespace: Optional[str] = None
288
+ self,
289
+ text: str,
290
+ namespace: Optional[str] = None,
291
+ idempotency_key: Optional[str] = None,
285
292
  ) -> RememberAcceptedResult:
286
293
  """Submit a remember request and return as soon as the server accepts it.
287
294
 
@@ -299,12 +306,27 @@ class MemWal:
299
306
  :class:`RememberAcceptedResult` with ``job_id`` and initial
300
307
  status (``"pending"``).
301
308
  """
309
+ resolved_namespace = namespace or self._namespace
310
+ request_identity = f"{resolved_namespace}\0{text}"
311
+ generated_key = idempotency_key is None
312
+ resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
313
+ if resolved_key is None:
314
+ resolved_key = str(uuid.uuid4())
315
+ if generated_key:
316
+ self._pending_remember_keys[request_identity] = resolved_key
317
+
302
318
  data = await self._signed_request(
303
319
  "POST",
304
320
  "/api/remember",
305
- {"text": text, "namespace": namespace or self._namespace},
321
+ {
322
+ "text": text,
323
+ "namespace": resolved_namespace,
324
+ "idempotency_key": resolved_key,
325
+ },
306
326
  accepted_statuses=(200, 202),
307
327
  )
328
+ if generated_key:
329
+ self._pending_remember_keys.pop(request_identity, None)
308
330
  return RememberAcceptedResult(
309
331
  job_id=data["job_id"],
310
332
  status=data.get("status", "pending"),
@@ -312,9 +334,12 @@ class MemWal:
312
334
 
313
335
  # Alias for parity with TS SDK ``rememberAsync``.
314
336
  async def remember_async(
315
- self, text: str, namespace: Optional[str] = None
337
+ self,
338
+ text: str,
339
+ namespace: Optional[str] = None,
340
+ idempotency_key: Optional[str] = None,
316
341
  ) -> RememberAcceptedResult:
317
- return await self.remember(text, namespace)
342
+ return await self.remember(text, namespace, idempotency_key)
318
343
 
319
344
  async def wait_for_remember_job(
320
345
  self,
@@ -380,6 +405,7 @@ class MemWal:
380
405
  namespace: Optional[str] = None,
381
406
  poll_interval_ms: int = 1500,
382
407
  timeout_ms: int = 60_000,
408
+ idempotency_key: Optional[str] = None,
383
409
  ) -> RememberResult:
384
410
  """Submit a remember and wait for the background worker to finish.
385
411
 
@@ -388,12 +414,24 @@ class MemWal:
388
414
  ``failed``). Mirrors TS ``rememberAndWait``.
389
415
  """
390
416
 
391
- accepted = await self.remember(text, namespace)
392
- return await self.wait_for_remember_job(
417
+ resolved_namespace = namespace or self._namespace
418
+ request_identity = f"{resolved_namespace}\0{text}"
419
+ generated_key = idempotency_key is None
420
+ resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
421
+ if resolved_key is None:
422
+ resolved_key = str(uuid.uuid4())
423
+ if generated_key:
424
+ self._pending_remember_keys[request_identity] = resolved_key
425
+
426
+ accepted = await self.remember(text, resolved_namespace, resolved_key)
427
+ result = await self.wait_for_remember_job(
393
428
  accepted.job_id,
394
429
  poll_interval_ms=poll_interval_ms,
395
430
  timeout_ms=timeout_ms,
396
431
  )
432
+ if generated_key:
433
+ self._pending_remember_keys.pop(request_identity, None)
434
+ return result
397
435
 
398
436
  # ============================================================
399
437
  # Bulk remember (ENG-1408)
@@ -1285,16 +1323,22 @@ class MemWalSync:
1285
1323
  return asyncio.run(coro)
1286
1324
 
1287
1325
  def remember(
1288
- self, text: str, namespace: Optional[str] = None
1326
+ self,
1327
+ text: str,
1328
+ namespace: Optional[str] = None,
1329
+ idempotency_key: Optional[str] = None,
1289
1330
  ) -> RememberAcceptedResult:
1290
1331
  """Synchronous version of :meth:`MemWal.remember` (async accept)."""
1291
- return self._run(self._inner.remember(text, namespace))
1332
+ return self._run(self._inner.remember(text, namespace, idempotency_key))
1292
1333
 
1293
1334
  # Alias for parity with TS SDK ``rememberAsync``.
1294
1335
  def remember_async(
1295
- self, text: str, namespace: Optional[str] = None
1336
+ self,
1337
+ text: str,
1338
+ namespace: Optional[str] = None,
1339
+ idempotency_key: Optional[str] = None,
1296
1340
  ) -> RememberAcceptedResult:
1297
- return self._run(self._inner.remember_async(text, namespace))
1341
+ return self._run(self._inner.remember_async(text, namespace, idempotency_key))
1298
1342
 
1299
1343
  def wait_for_remember_job(
1300
1344
  self,
@@ -1317,6 +1361,7 @@ class MemWalSync:
1317
1361
  namespace: Optional[str] = None,
1318
1362
  poll_interval_ms: int = 1500,
1319
1363
  timeout_ms: int = 60_000,
1364
+ idempotency_key: Optional[str] = None,
1320
1365
  ) -> RememberResult:
1321
1366
  """Synchronous version of :meth:`MemWal.remember_and_wait`."""
1322
1367
  return self._run(
@@ -1325,6 +1370,7 @@ class MemWalSync:
1325
1370
  namespace,
1326
1371
  poll_interval_ms=poll_interval_ms,
1327
1372
  timeout_ms=timeout_ms,
1373
+ idempotency_key=idempotency_key,
1328
1374
  )
1329
1375
  )
1330
1376
 
@@ -0,0 +1,585 @@
1
+ """Deterministic, dependency-free in-memory Walrus Memory client for tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import re
8
+ import unicodedata
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Optional, Sequence, Union
12
+
13
+ from .types import (
14
+ AnalyzedFact,
15
+ AnalyzeResult,
16
+ AnalyzeWaitResult,
17
+ AskMemory,
18
+ AskResult,
19
+ EmbedResult,
20
+ HealthResult,
21
+ RecallMemory,
22
+ RecallParams,
23
+ RecallResult,
24
+ RememberAcceptedResult,
25
+ RememberBulkAcceptedResult,
26
+ RememberBulkItem,
27
+ RememberBulkItemResult,
28
+ RememberBulkOptions,
29
+ RememberBulkResult,
30
+ RememberBulkStatusItem,
31
+ RememberBulkStatusResult,
32
+ RememberJobStatus,
33
+ RememberResult,
34
+ RestoreResult,
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class MemWalMockSeed:
40
+ """One optional memory loaded when a mock is created."""
41
+
42
+ text: str
43
+ namespace: Optional[str] = None
44
+ blob_id: Optional[str] = None
45
+
46
+
47
+ @dataclass
48
+ class _Memory:
49
+ id: str
50
+ job_id: str
51
+ blob_id: str
52
+ text: str
53
+ namespace: str
54
+ sequence: int
55
+
56
+
57
+ def _tokens(text: str) -> set[str]:
58
+ normalized = unicodedata.normalize("NFKC", text).casefold()
59
+ return set(re.findall(r"[^\W_]+", normalized, flags=re.UNICODE))
60
+
61
+
62
+ def _distance(query_tokens: set[str], text: str) -> float:
63
+ if not query_tokens:
64
+ return 1.0
65
+ matches = len(query_tokens.intersection(_tokens(text)))
66
+ return 1.0 - matches / len(query_tokens)
67
+
68
+
69
+ def _validate_text(text: str, field: str = "text") -> None:
70
+ if not isinstance(text, str) or not text.strip():
71
+ raise ValueError(f"{field} cannot be empty")
72
+
73
+
74
+ class MemWalMock:
75
+ """In-memory implementation of the common async :class:`MemWal` API.
76
+
77
+ The mock never creates an HTTP client or contacts Sui/Walrus. Recall uses
78
+ deterministic token-overlap distance; it is intentionally simple and is
79
+ designed for application tests, not relevance benchmarking.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ namespace: str = "default",
85
+ owner: str = "mock-owner",
86
+ initial_memories: Optional[Sequence[MemWalMockSeed]] = None,
87
+ ) -> None:
88
+ if not namespace:
89
+ raise ValueError("namespace cannot be empty")
90
+ self._namespace = namespace
91
+ self._owner = owner
92
+ self._memories: List[_Memory] = []
93
+ self._jobs: Dict[str, _Memory] = {}
94
+ self._sequence = 0
95
+ for seed in initial_memories or []:
96
+ self._store(seed.text, seed.namespace, seed.blob_id)
97
+
98
+ @classmethod
99
+ def create(
100
+ cls,
101
+ namespace: str = "default",
102
+ owner: str = "mock-owner",
103
+ initial_memories: Optional[Sequence[MemWalMockSeed]] = None,
104
+ ) -> "MemWalMock":
105
+ return cls(namespace, owner, initial_memories)
106
+
107
+ async def close(self) -> None:
108
+ """No-op for API parity with :class:`MemWal`."""
109
+
110
+ async def __aenter__(self) -> "MemWalMock":
111
+ return self
112
+
113
+ async def __aexit__(self, *exc: Any) -> None:
114
+ await self.close()
115
+
116
+ async def remember(
117
+ self, text: str, namespace: Optional[str] = None
118
+ ) -> RememberAcceptedResult:
119
+ memory = self._store(text, namespace)
120
+ return RememberAcceptedResult(job_id=memory.job_id, status="done")
121
+
122
+ async def remember_async(
123
+ self, text: str, namespace: Optional[str] = None
124
+ ) -> RememberAcceptedResult:
125
+ return await self.remember(text, namespace)
126
+
127
+ async def wait_for_remember_job(
128
+ self,
129
+ job_id: str,
130
+ poll_interval_ms: int = 1500,
131
+ timeout_ms: int = 60_000,
132
+ ) -> RememberResult:
133
+ del poll_interval_ms, timeout_ms
134
+ memory = self._jobs.get(job_id)
135
+ if memory is None:
136
+ raise KeyError(f"Remember job not found: {job_id}")
137
+ return self._remember_result(memory)
138
+
139
+ async def remember_and_wait(
140
+ self,
141
+ text: str,
142
+ namespace: Optional[str] = None,
143
+ poll_interval_ms: int = 1500,
144
+ timeout_ms: int = 60_000,
145
+ ) -> RememberResult:
146
+ accepted = await self.remember(text, namespace)
147
+ return await self.wait_for_remember_job(
148
+ accepted.job_id, poll_interval_ms, timeout_ms
149
+ )
150
+
151
+ async def get_remember_status(self, job_id: str) -> RememberJobStatus:
152
+ memory = self._jobs.get(job_id)
153
+ if memory is None:
154
+ return RememberJobStatus(job_id=job_id, status="not_found")
155
+ return RememberJobStatus(
156
+ job_id=job_id,
157
+ status="done",
158
+ owner=self._owner,
159
+ namespace=memory.namespace,
160
+ blob_id=memory.blob_id,
161
+ )
162
+
163
+ async def remember_bulk_async(
164
+ self, items: Sequence[RememberBulkItem]
165
+ ) -> RememberBulkAcceptedResult:
166
+ job_ids = [self._store(item.text, item.namespace).job_id for item in items]
167
+ return RememberBulkAcceptedResult(
168
+ job_ids=job_ids, total=len(job_ids), status="done"
169
+ )
170
+
171
+ async def remember_bulk(
172
+ self, items: Sequence[RememberBulkItem]
173
+ ) -> RememberBulkAcceptedResult:
174
+ return await self.remember_bulk_async(items)
175
+
176
+ async def get_remember_bulk_status(
177
+ self, job_ids: Sequence[str]
178
+ ) -> RememberBulkStatusResult:
179
+ results = []
180
+ for job_id in job_ids:
181
+ memory = self._jobs.get(job_id)
182
+ results.append(
183
+ RememberBulkStatusItem(
184
+ job_id=job_id,
185
+ status="done" if memory else "not_found",
186
+ blob_id=memory.blob_id if memory else None,
187
+ )
188
+ )
189
+ return RememberBulkStatusResult(results=results)
190
+
191
+ async def wait_for_remember_jobs(
192
+ self,
193
+ job_ids: Sequence[str],
194
+ opts: Optional[RememberBulkOptions] = None,
195
+ ) -> RememberBulkResult:
196
+ del opts
197
+ results: List[RememberBulkItemResult] = []
198
+ for job_id in job_ids:
199
+ memory = self._jobs.get(job_id)
200
+ results.append(
201
+ RememberBulkItemResult(
202
+ id=job_id,
203
+ blob_id=memory.blob_id if memory else "",
204
+ status="done" if memory else "failed",
205
+ error=None if memory else "Remember job not found",
206
+ )
207
+ )
208
+ succeeded = sum(result.status == "done" for result in results)
209
+ return RememberBulkResult(
210
+ results=results,
211
+ total=len(results),
212
+ succeeded=succeeded,
213
+ failed=len(results) - succeeded,
214
+ timed_out=0,
215
+ )
216
+
217
+ async def remember_bulk_and_wait(
218
+ self,
219
+ items: Sequence[RememberBulkItem],
220
+ opts: Optional[RememberBulkOptions] = None,
221
+ ) -> RememberBulkResult:
222
+ accepted = await self.remember_bulk_async(items)
223
+ return await self.wait_for_remember_jobs(accepted.job_ids, opts)
224
+
225
+ async def recall(
226
+ self,
227
+ query: Union[str, RecallParams],
228
+ limit: int = 10,
229
+ namespace: Optional[str] = None,
230
+ max_distance: Optional[float] = None,
231
+ ) -> RecallResult:
232
+ if isinstance(query, RecallParams):
233
+ limit = query.limit
234
+ namespace = query.namespace
235
+ max_distance = query.max_distance
236
+ query_text = query.query
237
+ else:
238
+ query_text = query
239
+ _validate_text(query_text, "query")
240
+ if not isinstance(limit, int) or limit < 0:
241
+ raise ValueError("limit must be a non-negative integer")
242
+ resolved_namespace = namespace or self._namespace
243
+ query_tokens = _tokens(query_text)
244
+ ranked = [
245
+ (memory, _distance(query_tokens, memory.text))
246
+ for memory in self._memories
247
+ if memory.namespace == resolved_namespace
248
+ ]
249
+ if max_distance is not None:
250
+ ranked = [item for item in ranked if item[1] < max_distance]
251
+ ranked.sort(key=lambda item: (item[1], item[0].sequence))
252
+ results = [
253
+ RecallMemory(blob_id=memory.blob_id, text=memory.text, distance=score)
254
+ for memory, score in ranked[:limit]
255
+ ]
256
+ return RecallResult(results=results, total=len(results))
257
+
258
+ async def analyze(
259
+ self,
260
+ text: str,
261
+ namespace: Optional[str] = None,
262
+ occurred_at: Optional[Union[str, datetime]] = None,
263
+ ) -> AnalyzeResult:
264
+ del occurred_at
265
+ memory = self._store(text, namespace)
266
+ return AnalyzeResult(
267
+ facts=[
268
+ AnalyzedFact(
269
+ text=text, id=memory.id, blob_id=memory.blob_id
270
+ )
271
+ ],
272
+ fact_count=1,
273
+ job_ids=[memory.job_id],
274
+ status="done",
275
+ owner=self._owner,
276
+ )
277
+
278
+ async def analyze_and_wait(
279
+ self,
280
+ text: str,
281
+ namespace: Optional[str] = None,
282
+ opts: Optional[RememberBulkOptions] = None,
283
+ occurred_at: Optional[Union[str, datetime]] = None,
284
+ ) -> AnalyzeWaitResult:
285
+ analyzed = await self.analyze(text, namespace, occurred_at=occurred_at)
286
+ settled = await self.wait_for_remember_jobs(analyzed.job_ids, opts)
287
+ return AnalyzeWaitResult(
288
+ results=settled.results,
289
+ total=settled.total,
290
+ succeeded=settled.succeeded,
291
+ failed=settled.failed,
292
+ timed_out=settled.timed_out,
293
+ facts=analyzed.facts,
294
+ owner=self._owner,
295
+ )
296
+
297
+ async def embed(self, text: str) -> EmbedResult:
298
+ _validate_text(text)
299
+ vector = [0.0] * 16
300
+ for token in _tokens(text):
301
+ value = 2166136261
302
+ for char in token:
303
+ value ^= ord(char)
304
+ value = (value * 16777619) & 0xFFFFFFFF
305
+ vector[value % len(vector)] += 1.0
306
+ magnitude = math.sqrt(sum(value * value for value in vector)) or 1.0
307
+ return EmbedResult(vector=[value / magnitude for value in vector])
308
+
309
+ async def ask(
310
+ self, question: str, limit: int = 5, namespace: Optional[str] = None
311
+ ) -> AskResult:
312
+ recalled = await self.recall(question, limit, namespace)
313
+ memories = [
314
+ AskMemory(
315
+ blob_id=memory.blob_id,
316
+ text=memory.text,
317
+ distance=memory.distance,
318
+ )
319
+ for memory in recalled.results
320
+ ]
321
+ return AskResult(
322
+ answer="\n".join(memory.text for memory in memories),
323
+ memories_used=len(memories),
324
+ memories=memories,
325
+ )
326
+
327
+ async def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
328
+ del limit
329
+ return RestoreResult(
330
+ restored=0,
331
+ skipped=0,
332
+ total=0,
333
+ namespace=namespace,
334
+ owner=self._owner,
335
+ truncated=False,
336
+ )
337
+
338
+ async def health(self) -> HealthResult:
339
+ return HealthResult(
340
+ status="ok",
341
+ version="memwal-mock",
342
+ relayer_version="memwal-mock",
343
+ api_version="1.0.0",
344
+ min_supported_sdk={"typescript": "0.0.0", "python": "0.0.0", "mcp": "0.0.0"},
345
+ feature_flags={"offlineMock": True},
346
+ deprecations=[],
347
+ build={},
348
+ )
349
+
350
+ async def compatibility(self) -> Dict[str, Any]:
351
+ return {
352
+ "relayerVersion": "memwal-mock",
353
+ "apiVersion": "1.0.0",
354
+ "minSupportedSdk": {
355
+ "typescript": "0.0.0",
356
+ "python": "0.0.0",
357
+ "mcp": "0.0.0",
358
+ },
359
+ "featureFlags": {"offlineMock": True},
360
+ "deprecations": [],
361
+ "build": {},
362
+ }
363
+
364
+ def forget(self, blob_id: str) -> bool:
365
+ for index, memory in enumerate(self._memories):
366
+ if memory.blob_id == blob_id:
367
+ self._memories.pop(index)
368
+ self._jobs.pop(memory.job_id, None)
369
+ return True
370
+ return False
371
+
372
+ def clear(self, namespace: Optional[str] = None) -> int:
373
+ removed = [
374
+ memory
375
+ for memory in self._memories
376
+ if namespace is None or memory.namespace == namespace
377
+ ]
378
+ self._memories = [
379
+ memory
380
+ for memory in self._memories
381
+ if namespace is not None and memory.namespace != namespace
382
+ ]
383
+ for memory in removed:
384
+ self._jobs.pop(memory.job_id, None)
385
+ return len(removed)
386
+
387
+ def _store(
388
+ self,
389
+ text: str,
390
+ namespace: Optional[str] = None,
391
+ blob_id: Optional[str] = None,
392
+ ) -> _Memory:
393
+ _validate_text(text)
394
+ resolved_namespace = namespace or self._namespace
395
+ if not resolved_namespace:
396
+ raise ValueError("namespace cannot be empty")
397
+ self._sequence += 1
398
+ suffix = f"{self._sequence:06d}"
399
+ memory = _Memory(
400
+ id=f"mock-job-{suffix}",
401
+ job_id=f"mock-job-{suffix}",
402
+ blob_id=blob_id or f"mock-blob-{suffix}",
403
+ text=text,
404
+ namespace=resolved_namespace,
405
+ sequence=self._sequence,
406
+ )
407
+ self._memories.append(memory)
408
+ self._jobs[memory.job_id] = memory
409
+ return memory
410
+
411
+ def _remember_result(self, memory: _Memory) -> RememberResult:
412
+ return RememberResult(
413
+ id=memory.id,
414
+ blob_id=memory.blob_id,
415
+ owner=self._owner,
416
+ namespace=memory.namespace,
417
+ )
418
+
419
+
420
+ class MemWalMockSync:
421
+ """Synchronous wrapper around :class:`MemWalMock` for non-async tests."""
422
+
423
+ def __init__(self, inner: MemWalMock) -> None:
424
+ self._inner = inner
425
+
426
+ @classmethod
427
+ def create(
428
+ cls,
429
+ namespace: str = "default",
430
+ owner: str = "mock-owner",
431
+ initial_memories: Optional[Sequence[MemWalMockSeed]] = None,
432
+ ) -> "MemWalMockSync":
433
+ return cls(MemWalMock.create(namespace, owner, initial_memories))
434
+
435
+ @staticmethod
436
+ def _run(coro: Any) -> Any:
437
+ try:
438
+ loop = asyncio.get_running_loop()
439
+ except RuntimeError:
440
+ loop = None
441
+
442
+ if loop is not None and loop.is_running():
443
+ # Match MemWalSync in notebooks and other hosts with a live loop:
444
+ # execute the coroutine on a fresh event loop in a worker thread.
445
+ import concurrent.futures
446
+
447
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
448
+ return pool.submit(asyncio.run, coro).result()
449
+ return asyncio.run(coro)
450
+
451
+ def remember(self, text: str, namespace: Optional[str] = None) -> RememberAcceptedResult:
452
+ return self._run(self._inner.remember(text, namespace))
453
+
454
+ def remember_async(
455
+ self, text: str, namespace: Optional[str] = None
456
+ ) -> RememberAcceptedResult:
457
+ return self.remember(text, namespace)
458
+
459
+ def wait_for_remember_job(
460
+ self,
461
+ job_id: str,
462
+ poll_interval_ms: int = 1500,
463
+ timeout_ms: int = 60_000,
464
+ ) -> RememberResult:
465
+ return self._run(
466
+ self._inner.wait_for_remember_job(
467
+ job_id,
468
+ poll_interval_ms=poll_interval_ms,
469
+ timeout_ms=timeout_ms,
470
+ )
471
+ )
472
+
473
+ def get_remember_status(self, job_id: str) -> RememberJobStatus:
474
+ return self._run(self._inner.get_remember_status(job_id))
475
+
476
+ def remember_and_wait(
477
+ self,
478
+ text: str,
479
+ namespace: Optional[str] = None,
480
+ poll_interval_ms: int = 1500,
481
+ timeout_ms: int = 60_000,
482
+ ) -> RememberResult:
483
+ return self._run(
484
+ self._inner.remember_and_wait(
485
+ text,
486
+ namespace,
487
+ poll_interval_ms=poll_interval_ms,
488
+ timeout_ms=timeout_ms,
489
+ )
490
+ )
491
+
492
+ def remember_bulk_async(
493
+ self, items: Sequence[RememberBulkItem]
494
+ ) -> RememberBulkAcceptedResult:
495
+ return self._run(self._inner.remember_bulk_async(items))
496
+
497
+ def remember_bulk(
498
+ self, items: Sequence[RememberBulkItem]
499
+ ) -> RememberBulkAcceptedResult:
500
+ return self.remember_bulk_async(items)
501
+
502
+ def get_remember_bulk_status(
503
+ self, job_ids: Sequence[str]
504
+ ) -> RememberBulkStatusResult:
505
+ return self._run(self._inner.get_remember_bulk_status(job_ids))
506
+
507
+ def wait_for_remember_jobs(
508
+ self,
509
+ job_ids: Sequence[str],
510
+ opts: Optional[RememberBulkOptions] = None,
511
+ ) -> RememberBulkResult:
512
+ return self._run(self._inner.wait_for_remember_jobs(job_ids, opts))
513
+
514
+ def remember_bulk_and_wait(
515
+ self,
516
+ items: Sequence[RememberBulkItem],
517
+ opts: Optional[RememberBulkOptions] = None,
518
+ ) -> RememberBulkResult:
519
+ return self._run(self._inner.remember_bulk_and_wait(items, opts))
520
+
521
+ def recall(
522
+ self,
523
+ query: Union[str, RecallParams],
524
+ limit: int = 10,
525
+ namespace: Optional[str] = None,
526
+ max_distance: Optional[float] = None,
527
+ ) -> RecallResult:
528
+ return self._run(
529
+ self._inner.recall(query, limit, namespace, max_distance)
530
+ )
531
+
532
+ def analyze(
533
+ self,
534
+ text: str,
535
+ namespace: Optional[str] = None,
536
+ occurred_at: Optional[Union[str, datetime]] = None,
537
+ ) -> AnalyzeResult:
538
+ return self._run(
539
+ self._inner.analyze(text, namespace, occurred_at=occurred_at)
540
+ )
541
+
542
+ def analyze_and_wait(
543
+ self,
544
+ text: str,
545
+ namespace: Optional[str] = None,
546
+ opts: Optional[RememberBulkOptions] = None,
547
+ occurred_at: Optional[Union[str, datetime]] = None,
548
+ ) -> AnalyzeWaitResult:
549
+ return self._run(
550
+ self._inner.analyze_and_wait(
551
+ text, namespace, opts, occurred_at=occurred_at
552
+ )
553
+ )
554
+
555
+ def embed(self, text: str) -> EmbedResult:
556
+ return self._run(self._inner.embed(text))
557
+
558
+ def ask(
559
+ self, question: str, limit: int = 5, namespace: Optional[str] = None
560
+ ) -> AskResult:
561
+ return self._run(self._inner.ask(question, limit, namespace))
562
+
563
+ def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
564
+ return self._run(self._inner.restore(namespace, limit))
565
+
566
+ def health(self) -> HealthResult:
567
+ return self._run(self._inner.health())
568
+
569
+ def compatibility(self) -> Dict[str, Any]:
570
+ return self._run(self._inner.compatibility())
571
+
572
+ def forget(self, blob_id: str) -> bool:
573
+ return self._inner.forget(blob_id)
574
+
575
+ def clear(self, namespace: Optional[str] = None) -> int:
576
+ return self._inner.clear(namespace)
577
+
578
+ def close(self) -> None:
579
+ self._run(self._inner.close())
580
+
581
+ def __enter__(self) -> "MemWalMockSync":
582
+ return self
583
+
584
+ def __exit__(self, *exc: Any) -> None:
585
+ self.close()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.6.dev0"
7
+ version = "0.1.6.dev2"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,162 @@
1
+ """Offline mock client regression tests."""
2
+
3
+ import inspect
4
+
5
+ import pytest
6
+
7
+ from memwal import (
8
+ MemWalMock,
9
+ MemWalMockSeed,
10
+ MemWalMockSync,
11
+ MemWalSync,
12
+ RecallParams,
13
+ RememberBulkItem,
14
+ RememberBulkOptions,
15
+ )
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_mock_remember_and_recall_are_deterministic_and_offline(monkeypatch):
20
+ def reject_network(*args, **kwargs):
21
+ raise AssertionError("MemWalMock must not create a network client")
22
+
23
+ monkeypatch.setattr("httpx.AsyncClient", reject_network)
24
+ mock = MemWalMock.create(namespace="user-a", owner="test-owner")
25
+ coffee = await mock.remember_and_wait("I prefer coffee in the morning")
26
+ tea = await mock.remember_and_wait("I drink tea at night")
27
+
28
+ assert coffee.id == "mock-job-000001"
29
+ assert coffee.blob_id == "mock-blob-000001"
30
+ assert coffee.namespace == "user-a"
31
+ assert tea.blob_id == "mock-blob-000002"
32
+
33
+ recalled = await mock.recall(RecallParams(query="morning coffee", limit=2))
34
+ assert [memory.text for memory in recalled.results] == [
35
+ "I prefer coffee in the morning",
36
+ "I drink tea at night",
37
+ ]
38
+ assert recalled.results[0].distance == 0
39
+ assert recalled.results[1].distance == 1
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_mock_isolates_namespaces_and_honors_max_distance():
44
+ mock = MemWalMock.create()
45
+ await mock.remember_and_wait("Alice likes ramen", "user-a")
46
+ await mock.remember_and_wait("Bob likes tacos", "user-b")
47
+
48
+ alice = await mock.recall(
49
+ RecallParams(query="likes", namespace="user-a", max_distance=0.5)
50
+ )
51
+ bob = await mock.recall("likes", namespace="user-b")
52
+ empty = await mock.recall("likes")
53
+
54
+ assert [memory.text for memory in alice.results] == ["Alice likes ramen"]
55
+ assert [memory.text for memory in bob.results] == ["Bob likes tacos"]
56
+ assert empty.total == 0
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_mock_supports_jobs_bulk_analyze_forget_and_clear():
61
+ mock = MemWalMock.create()
62
+ accepted = await mock.remember("single fact")
63
+ status = await mock.get_remember_status(accepted.job_id)
64
+ assert status.status == "done"
65
+ assert status.blob_id == "mock-blob-000001"
66
+
67
+ bulk = await mock.remember_bulk_and_wait(
68
+ [
69
+ RememberBulkItem(text="bulk one", namespace="one"),
70
+ RememberBulkItem(text="bulk two", namespace="two"),
71
+ ]
72
+ )
73
+ assert bulk.succeeded == 2
74
+ assert bulk.failed == 0
75
+
76
+ analyzed = await mock.analyze_and_wait("durable analyzed fact", "analysis")
77
+ assert analyzed.facts[0].text == "durable analyzed fact"
78
+
79
+ assert mock.forget("mock-blob-000001") is True
80
+ assert mock.forget("missing") is False
81
+ assert mock.clear("one") == 1
82
+ assert (await mock.recall("bulk", namespace="one")).total == 0
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_mock_seed_embed_health_and_compatibility():
87
+ first = MemWalMock.create(
88
+ initial_memories=[
89
+ MemWalMockSeed(text="seed memory", namespace="seed", blob_id="seed-blob")
90
+ ]
91
+ )
92
+ second = MemWalMock.create()
93
+
94
+ assert await first.embed("same text") == await second.embed("same text")
95
+ recalled = await first.recall("seed", namespace="seed")
96
+ assert recalled.results[0].blob_id == "seed-blob"
97
+ assert (await first.health()).status == "ok"
98
+ assert (await first.compatibility())["featureFlags"]["offlineMock"] is True
99
+
100
+
101
+ def test_sync_mock_wraps_core_flows_and_accepts_production_options():
102
+ mock = MemWalMockSync.create(namespace="sync")
103
+ opts = RememberBulkOptions(poll_interval_ms=1, timeout_ms=10)
104
+
105
+ accepted = mock.remember("accepted memory")
106
+ stored = mock.wait_for_remember_job(
107
+ accepted.job_id, poll_interval_ms=1, timeout_ms=10
108
+ )
109
+ waited = mock.remember_and_wait(
110
+ "sync memory", poll_interval_ms=1, timeout_ms=10
111
+ )
112
+ bulk = mock.remember_bulk_and_wait(
113
+ [RememberBulkItem(text="bulk memory")], opts=opts
114
+ )
115
+ assert mock.wait_for_remember_jobs([accepted.job_id], opts=opts).succeeded == 1
116
+ analyzed = mock.analyze("analyzed memory", occurred_at="2024-01-01T00:00:00Z")
117
+ analyzed_wait = mock.analyze_and_wait(
118
+ "analyzed and waited",
119
+ opts=opts,
120
+ occurred_at="2024-01-01T00:00:00Z",
121
+ )
122
+ recalled = mock.recall("sync")
123
+
124
+ assert stored.blob_id == "mock-blob-000001"
125
+ assert waited.namespace == "sync"
126
+ assert bulk.succeeded == 1
127
+ assert analyzed.fact_count == 1
128
+ assert analyzed_wait.succeeded == 1
129
+ assert recalled.results[0].text == "sync memory"
130
+
131
+
132
+ def test_sync_mock_polling_and_analyze_signatures_match_production():
133
+ methods = (
134
+ "wait_for_remember_job",
135
+ "remember_and_wait",
136
+ "wait_for_remember_jobs",
137
+ "remember_bulk_and_wait",
138
+ "analyze",
139
+ "analyze_and_wait",
140
+ )
141
+
142
+ def parameter_contract(method):
143
+ return [
144
+ (parameter.name, parameter.kind, parameter.default)
145
+ for parameter in inspect.signature(method).parameters.values()
146
+ ]
147
+
148
+ for method_name in methods:
149
+ assert parameter_contract(getattr(MemWalMockSync, method_name)) == parameter_contract(
150
+ getattr(MemWalSync, method_name)
151
+ )
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_sync_mock_works_inside_an_existing_event_loop():
156
+ mock = MemWalMockSync.create(namespace="notebook")
157
+
158
+ stored = mock.remember_and_wait("called from a running loop")
159
+ recalled = mock.recall("running loop")
160
+
161
+ assert stored.namespace == "notebook"
162
+ assert recalled.results[0].text == "called from a running loop"
File without changes
File without changes
File without changes
File without changes
File without changes