memwal 0.1.6.dev0__tar.gz → 0.1.6.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/PKG-INFO +19 -2
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/README.md +17 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/memwal/__init__.py +5 -1
- memwal-0.1.6.dev1/memwal/mock.py +585 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/pyproject.toml +1 -1
- memwal-0.1.6.dev1/tests/test_mock.py +162 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/.gitignore +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/CHANGELOG.md +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/examples/.env.example +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/examples/.gitignore +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/examples/interactive_demo.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/examples/verify_credentials.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/memwal/client.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/memwal/compatibility.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/memwal/middleware.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/memwal/types.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/memwal/utils.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/run_tests.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/__init__.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/test_client.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/test_env_presets.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/test_integration.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/test_middleware.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev1}/tests/test_signing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.6.
|
|
3
|
+
Version: 0.1.6.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
|
|
@@ -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.
|
|
123
|
+
__version__ = "0.1.6.dev1"
|
|
@@ -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()
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|