memwal 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memwal/__init__.py +111 -0
- memwal/client.py +1014 -0
- memwal/middleware.py +472 -0
- memwal/types.py +340 -0
- memwal/utils.py +172 -0
- memwal-0.1.0.dev0.dist-info/METADATA +211 -0
- memwal-0.1.0.dev0.dist-info/RECORD +8 -0
- memwal-0.1.0.dev0.dist-info/WHEEL +4 -0
memwal/client.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memwal — SDK Client
|
|
3
|
+
|
|
4
|
+
Ed25519 delegate key based client that communicates with the MemWal
|
|
5
|
+
Rust server (TEE). All data processing (encryption, embedding, Walrus)
|
|
6
|
+
happens server-side -- the SDK just signs requests and sends text.
|
|
7
|
+
|
|
8
|
+
The SDK only needs a single Ed25519 private key (the "delegate key").
|
|
9
|
+
The server derives the owner address from the public key via onchain
|
|
10
|
+
lookup in MemWalAccount.delegate_keys.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
from memwal import MemWal
|
|
15
|
+
|
|
16
|
+
memwal = MemWal.create(
|
|
17
|
+
key="abcdef...",
|
|
18
|
+
account_id="0x...",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Async usage
|
|
22
|
+
result = await memwal.remember("I'm allergic to peanuts")
|
|
23
|
+
matches = await memwal.recall("food allergies")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import random
|
|
31
|
+
import time
|
|
32
|
+
from typing import Any, Dict, List, Optional, Sequence, TypeVar
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from .types import (
|
|
37
|
+
AnalyzedFact,
|
|
38
|
+
AnalyzeResult,
|
|
39
|
+
AnalyzeWaitResult,
|
|
40
|
+
AskMemory,
|
|
41
|
+
AskResult,
|
|
42
|
+
EmbedResult,
|
|
43
|
+
HealthResult,
|
|
44
|
+
MemWalConfig,
|
|
45
|
+
RecallManualHit,
|
|
46
|
+
RecallManualOptions,
|
|
47
|
+
RecallManualResult,
|
|
48
|
+
RecallMemory,
|
|
49
|
+
RecallResult,
|
|
50
|
+
RememberAcceptedResult,
|
|
51
|
+
RememberBulkAcceptedResult,
|
|
52
|
+
RememberBulkItem,
|
|
53
|
+
RememberBulkItemResult,
|
|
54
|
+
RememberBulkOptions,
|
|
55
|
+
RememberBulkResult,
|
|
56
|
+
RememberBulkStatusItem,
|
|
57
|
+
RememberBulkStatusResult,
|
|
58
|
+
RememberManualOptions,
|
|
59
|
+
RememberManualResult,
|
|
60
|
+
RememberResult,
|
|
61
|
+
RestoreResult,
|
|
62
|
+
)
|
|
63
|
+
from .utils import (
|
|
64
|
+
build_signature_message,
|
|
65
|
+
build_signing_key,
|
|
66
|
+
bytes_to_hex,
|
|
67
|
+
sha256_hex,
|
|
68
|
+
sign_message,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
T = TypeVar("T")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ============================================================
|
|
75
|
+
# Polling helpers (PR #121 parity with TS SDK)
|
|
76
|
+
# ============================================================
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _now_ms() -> int:
|
|
80
|
+
return int(time.monotonic() * 1000)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _sleep_ms(ms: int) -> None:
|
|
84
|
+
await asyncio.sleep(max(ms, 0) / 1000.0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _polling_delay_ms(base_ms: int, attempt: int) -> int:
|
|
88
|
+
"""Jittered exponential backoff matching TS ``pollingDelayMs``.
|
|
89
|
+
|
|
90
|
+
base * 1.5^min(attempt, 6), capped at 10s, with ±25% jitter so
|
|
91
|
+
concurrent clients don't synchronise.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
base = max(100, base_ms)
|
|
95
|
+
capped = min(10_000, base * (1.5 ** min(attempt, 6)))
|
|
96
|
+
jitter = 0.75 + random.random() * 0.5
|
|
97
|
+
return int(capped * jitter)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_transient_polling_status(status: int) -> bool:
|
|
101
|
+
"""Classify HTTP status codes for the polling retry loop.
|
|
102
|
+
|
|
103
|
+
Mirrors TS ``isTransientPollingStatus``: connection drop (0), rate
|
|
104
|
+
limit (429), or any 5xx → retry. Anything else (including 4xx other
|
|
105
|
+
than 404 which is special-cased upstream) → surface to caller.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
return status == 0 or status == 429 or status >= 500
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MemWal:
|
|
112
|
+
"""Async-native MemWal client.
|
|
113
|
+
|
|
114
|
+
All API methods are ``async``. For synchronous usage, wrap calls with
|
|
115
|
+
``asyncio.run()`` or use the :class:`MemWalSync` convenience wrapper.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, config: MemWalConfig) -> None:
|
|
119
|
+
self._signing_key = build_signing_key(config.key)
|
|
120
|
+
self._private_key_hex = config.key if not config.key.startswith("0x") else config.key[2:]
|
|
121
|
+
self._account_id = config.account_id
|
|
122
|
+
self._server_url = config.server_url.rstrip("/")
|
|
123
|
+
self._namespace = config.namespace
|
|
124
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def create(
|
|
128
|
+
cls,
|
|
129
|
+
key: str,
|
|
130
|
+
account_id: str,
|
|
131
|
+
server_url: str = "https://relayer.memwal.ai",
|
|
132
|
+
namespace: str = "default",
|
|
133
|
+
) -> "MemWal":
|
|
134
|
+
"""Create a new MemWal client instance.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
key: Ed25519 private key hex string (the delegate key).
|
|
138
|
+
account_id: MemWalAccount object ID on Sui.
|
|
139
|
+
server_url: Server URL (default: ``https://relayer.memwal.ai``).
|
|
140
|
+
namespace: Default namespace for memory isolation (default: ``"default"``).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A configured :class:`MemWal` instance.
|
|
144
|
+
"""
|
|
145
|
+
config = MemWalConfig(
|
|
146
|
+
key=key,
|
|
147
|
+
account_id=account_id,
|
|
148
|
+
server_url=server_url,
|
|
149
|
+
namespace=namespace,
|
|
150
|
+
)
|
|
151
|
+
return cls(config)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def _http(self) -> httpx.AsyncClient:
|
|
155
|
+
"""Lazily create the async HTTP client."""
|
|
156
|
+
if self._client is None or self._client.is_closed:
|
|
157
|
+
self._client = httpx.AsyncClient(timeout=60.0)
|
|
158
|
+
return self._client
|
|
159
|
+
|
|
160
|
+
async def close(self) -> None:
|
|
161
|
+
"""Close the underlying HTTP client."""
|
|
162
|
+
if self._client is not None and not self._client.is_closed:
|
|
163
|
+
await self._client.aclose()
|
|
164
|
+
self._client = None
|
|
165
|
+
|
|
166
|
+
async def __aenter__(self) -> "MemWal":
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
170
|
+
await self.close()
|
|
171
|
+
|
|
172
|
+
# ============================================================
|
|
173
|
+
# Core API
|
|
174
|
+
# ============================================================
|
|
175
|
+
|
|
176
|
+
async def remember(
|
|
177
|
+
self, text: str, namespace: Optional[str] = None
|
|
178
|
+
) -> RememberAcceptedResult:
|
|
179
|
+
"""Submit a remember request and return as soon as the server accepts it.
|
|
180
|
+
|
|
181
|
+
Per PR #121 (ENG-1406): the server returns ``HTTP 202 + job_id``
|
|
182
|
+
immediately (~500ms). The actual Walrus upload + on-chain commit run
|
|
183
|
+
in a background worker. Use :meth:`wait_for_remember_job` to follow
|
|
184
|
+
the job to completion, or :meth:`remember_and_wait` for a single
|
|
185
|
+
call that does both.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
text: The text to remember.
|
|
189
|
+
namespace: Override the default namespace.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
:class:`RememberAcceptedResult` with ``job_id`` and initial
|
|
193
|
+
status (``"pending"``).
|
|
194
|
+
"""
|
|
195
|
+
data = await self._signed_request(
|
|
196
|
+
"POST",
|
|
197
|
+
"/api/remember",
|
|
198
|
+
{"text": text, "namespace": namespace or self._namespace},
|
|
199
|
+
accepted_statuses=(200, 202),
|
|
200
|
+
)
|
|
201
|
+
return RememberAcceptedResult(
|
|
202
|
+
job_id=data["job_id"],
|
|
203
|
+
status=data.get("status", "pending"),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Alias for parity with TS SDK ``rememberAsync``.
|
|
207
|
+
async def remember_async(
|
|
208
|
+
self, text: str, namespace: Optional[str] = None
|
|
209
|
+
) -> RememberAcceptedResult:
|
|
210
|
+
return await self.remember(text, namespace)
|
|
211
|
+
|
|
212
|
+
async def wait_for_remember_job(
|
|
213
|
+
self,
|
|
214
|
+
job_id: str,
|
|
215
|
+
poll_interval_ms: int = 1500,
|
|
216
|
+
timeout_ms: int = 60_000,
|
|
217
|
+
) -> RememberResult:
|
|
218
|
+
"""Poll an accepted remember job until it reaches a terminal state.
|
|
219
|
+
|
|
220
|
+
Mirrors TS ``waitForRememberJob``:
|
|
221
|
+
|
|
222
|
+
- Accepts 200 + 404 from the status endpoint and dispatches on
|
|
223
|
+
``status`` field (404 / ``status == "not_found"`` raises).
|
|
224
|
+
- Transient HTTP errors (429, 5xx, network drop) are retried until
|
|
225
|
+
the timeout, not surfaced as polling failures.
|
|
226
|
+
- Backoff is jittered exponential (1.5x cap 10s, ±25%) to avoid
|
|
227
|
+
thundering-herd at scale.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
deadline_ms = _now_ms() + timeout_ms
|
|
231
|
+
attempt = 0
|
|
232
|
+
|
|
233
|
+
while _now_ms() < deadline_ms:
|
|
234
|
+
await _sleep_ms(_polling_delay_ms(poll_interval_ms, attempt))
|
|
235
|
+
attempt += 1
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
data = await self._signed_request(
|
|
239
|
+
"GET",
|
|
240
|
+
f"/api/remember/{job_id}",
|
|
241
|
+
{},
|
|
242
|
+
accepted_statuses=(200, 404),
|
|
243
|
+
)
|
|
244
|
+
except _HttpStatusError as err:
|
|
245
|
+
if _is_transient_polling_status(err.status):
|
|
246
|
+
continue
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
status_str = data.get("status")
|
|
250
|
+
if status_str is None or status_str == "not_found":
|
|
251
|
+
raise MemWalRememberJobNotFound(job_id)
|
|
252
|
+
|
|
253
|
+
if status_str == "done":
|
|
254
|
+
return RememberResult(
|
|
255
|
+
id=data.get("job_id", job_id),
|
|
256
|
+
blob_id=data.get("blob_id") or "",
|
|
257
|
+
owner=data.get("owner") or "",
|
|
258
|
+
namespace=data.get("namespace") or self._namespace,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if status_str == "failed":
|
|
262
|
+
raise MemWalRememberJobFailed(
|
|
263
|
+
job_id=job_id, error=data.get("error") or "unknown error"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# pending / running / uploaded — keep polling.
|
|
267
|
+
|
|
268
|
+
raise MemWalRememberJobTimeout(job_id=job_id, timeout_ms=timeout_ms)
|
|
269
|
+
|
|
270
|
+
async def remember_and_wait(
|
|
271
|
+
self,
|
|
272
|
+
text: str,
|
|
273
|
+
namespace: Optional[str] = None,
|
|
274
|
+
poll_interval_ms: int = 1500,
|
|
275
|
+
timeout_ms: int = 60_000,
|
|
276
|
+
) -> RememberResult:
|
|
277
|
+
"""Submit a remember and wait for the background worker to finish.
|
|
278
|
+
|
|
279
|
+
Convenience wrapper around :meth:`remember` (returns ``job_id`` in
|
|
280
|
+
~500ms) + :meth:`wait_for_remember_job` (polls until ``done`` or
|
|
281
|
+
``failed``). Mirrors TS ``rememberAndWait``.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
accepted = await self.remember(text, namespace)
|
|
285
|
+
return await self.wait_for_remember_job(
|
|
286
|
+
accepted.job_id,
|
|
287
|
+
poll_interval_ms=poll_interval_ms,
|
|
288
|
+
timeout_ms=timeout_ms,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# ============================================================
|
|
292
|
+
# Bulk remember (ENG-1408)
|
|
293
|
+
# ============================================================
|
|
294
|
+
|
|
295
|
+
async def remember_bulk_async(
|
|
296
|
+
self, items: Sequence[RememberBulkItem]
|
|
297
|
+
) -> RememberBulkAcceptedResult:
|
|
298
|
+
"""Submit a bulk remember and return as soon as the server accepts the batch.
|
|
299
|
+
|
|
300
|
+
Server returns ``HTTP 202`` with ``job_ids`` aligned positionally with
|
|
301
|
+
``items``. Each item then progresses through the same async pipeline
|
|
302
|
+
as :meth:`remember` independently.
|
|
303
|
+
|
|
304
|
+
Up to ``MAX_BULK_ITEMS`` (= 20) items per call; each item's text
|
|
305
|
+
capped at ``MAX_REMEMBER_TEXT_BYTES`` (= 64 KiB).
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
payload_items: List[Dict[str, Any]] = [
|
|
309
|
+
{
|
|
310
|
+
"text": item.text,
|
|
311
|
+
"namespace": item.namespace or self._namespace,
|
|
312
|
+
}
|
|
313
|
+
for item in items
|
|
314
|
+
]
|
|
315
|
+
data = await self._signed_request(
|
|
316
|
+
"POST",
|
|
317
|
+
"/api/remember/bulk",
|
|
318
|
+
{"items": payload_items},
|
|
319
|
+
accepted_statuses=(200, 202),
|
|
320
|
+
)
|
|
321
|
+
return RememberBulkAcceptedResult(
|
|
322
|
+
job_ids=list(data.get("job_ids", [])),
|
|
323
|
+
total=int(data.get("total", len(payload_items))),
|
|
324
|
+
status=data.get("status", "pending"),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Alias for parity with TS SDK ``rememberBulk``.
|
|
328
|
+
async def remember_bulk(
|
|
329
|
+
self, items: Sequence[RememberBulkItem]
|
|
330
|
+
) -> RememberBulkAcceptedResult:
|
|
331
|
+
return await self.remember_bulk_async(items)
|
|
332
|
+
|
|
333
|
+
async def get_remember_bulk_status(
|
|
334
|
+
self, job_ids: Sequence[str]
|
|
335
|
+
) -> RememberBulkStatusResult:
|
|
336
|
+
"""Poll the bulk-status endpoint for a batch of job_ids.
|
|
337
|
+
|
|
338
|
+
Returns one :class:`RememberBulkStatusItem` per requested job (order
|
|
339
|
+
not guaranteed; callers should index by ``job_id``).
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
data = await self._signed_request(
|
|
343
|
+
"POST",
|
|
344
|
+
"/api/remember/bulk/status",
|
|
345
|
+
{"job_ids": list(job_ids)},
|
|
346
|
+
)
|
|
347
|
+
return RememberBulkStatusResult(
|
|
348
|
+
results=[
|
|
349
|
+
RememberBulkStatusItem(
|
|
350
|
+
job_id=item.get("job_id", ""),
|
|
351
|
+
status=item.get("status", "pending"),
|
|
352
|
+
blob_id=item.get("blob_id"),
|
|
353
|
+
error=item.get("error"),
|
|
354
|
+
)
|
|
355
|
+
for item in data.get("results", [])
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async def wait_for_remember_jobs(
|
|
360
|
+
self,
|
|
361
|
+
job_ids: Sequence[str],
|
|
362
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
363
|
+
) -> RememberBulkResult:
|
|
364
|
+
"""Poll the bulk-status endpoint until every job is terminal.
|
|
365
|
+
|
|
366
|
+
Mirrors TS ``waitForRememberJobs``:
|
|
367
|
+
|
|
368
|
+
- Each item settles to ``"done"``, ``"failed"``, or ``"timeout"``.
|
|
369
|
+
- Same transient-retry + jitter strategy as the single-job poll.
|
|
370
|
+
- Result list preserves the order of the input ``job_ids``.
|
|
371
|
+
|
|
372
|
+
Default ``timeout_ms`` is 120s — bulk pipelines run longer than
|
|
373
|
+
single remember.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
opts = opts or RememberBulkOptions()
|
|
377
|
+
deadline_ms = _now_ms() + opts.timeout_ms
|
|
378
|
+
|
|
379
|
+
# Track per-job final state.
|
|
380
|
+
results: Dict[str, RememberBulkItemResult] = {
|
|
381
|
+
job_id: RememberBulkItemResult(
|
|
382
|
+
id=job_id,
|
|
383
|
+
blob_id="",
|
|
384
|
+
status="timeout",
|
|
385
|
+
error=None,
|
|
386
|
+
)
|
|
387
|
+
for job_id in job_ids
|
|
388
|
+
}
|
|
389
|
+
pending: List[str] = list(job_ids)
|
|
390
|
+
attempt = 0
|
|
391
|
+
|
|
392
|
+
while pending and _now_ms() < deadline_ms:
|
|
393
|
+
await _sleep_ms(_polling_delay_ms(opts.poll_interval_ms, attempt))
|
|
394
|
+
attempt += 1
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
batch = await self.get_remember_bulk_status(pending)
|
|
398
|
+
except _HttpStatusError as err:
|
|
399
|
+
if _is_transient_polling_status(err.status):
|
|
400
|
+
continue
|
|
401
|
+
raise
|
|
402
|
+
|
|
403
|
+
still_pending: List[str] = []
|
|
404
|
+
for item in batch.results:
|
|
405
|
+
if item.status == "done":
|
|
406
|
+
results[item.job_id] = RememberBulkItemResult(
|
|
407
|
+
id=item.job_id,
|
|
408
|
+
blob_id=item.blob_id or "",
|
|
409
|
+
status="done",
|
|
410
|
+
error=None,
|
|
411
|
+
)
|
|
412
|
+
elif item.status in ("failed", "not_found"):
|
|
413
|
+
results[item.job_id] = RememberBulkItemResult(
|
|
414
|
+
id=item.job_id,
|
|
415
|
+
blob_id=item.blob_id or "",
|
|
416
|
+
status="failed",
|
|
417
|
+
error=item.error,
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
still_pending.append(item.job_id)
|
|
421
|
+
pending = still_pending
|
|
422
|
+
|
|
423
|
+
ordered = [results[job_id] for job_id in job_ids]
|
|
424
|
+
succeeded = sum(1 for r in ordered if r.status == "done")
|
|
425
|
+
failed = sum(1 for r in ordered if r.status == "failed")
|
|
426
|
+
timed_out = sum(1 for r in ordered if r.status == "timeout")
|
|
427
|
+
return RememberBulkResult(
|
|
428
|
+
results=ordered,
|
|
429
|
+
total=len(ordered),
|
|
430
|
+
succeeded=succeeded,
|
|
431
|
+
failed=failed,
|
|
432
|
+
timed_out=timed_out,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
async def remember_bulk_and_wait(
|
|
436
|
+
self,
|
|
437
|
+
items: Sequence[RememberBulkItem],
|
|
438
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
439
|
+
) -> RememberBulkResult:
|
|
440
|
+
"""Submit bulk + wait for every item to settle.
|
|
441
|
+
|
|
442
|
+
Convenience wrapper around :meth:`remember_bulk_async` +
|
|
443
|
+
:meth:`wait_for_remember_jobs`. Mirrors TS ``rememberBulkAndWait``.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
accepted = await self.remember_bulk_async(items)
|
|
447
|
+
return await self.wait_for_remember_jobs(accepted.job_ids, opts)
|
|
448
|
+
|
|
449
|
+
async def recall(
|
|
450
|
+
self,
|
|
451
|
+
query: str,
|
|
452
|
+
limit: int = 10,
|
|
453
|
+
namespace: Optional[str] = None,
|
|
454
|
+
) -> RecallResult:
|
|
455
|
+
"""Recall memories similar to a query.
|
|
456
|
+
|
|
457
|
+
Server handles: verify -> embed query -> search -> Walrus download -> decrypt.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
query: Search query.
|
|
461
|
+
limit: Max number of results (default: 10).
|
|
462
|
+
namespace: Override the default namespace.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
:class:`RecallResult` with decrypted text results.
|
|
466
|
+
"""
|
|
467
|
+
data = await self._signed_request("POST", "/api/recall", {
|
|
468
|
+
"query": query,
|
|
469
|
+
"limit": limit,
|
|
470
|
+
"namespace": namespace or self._namespace,
|
|
471
|
+
})
|
|
472
|
+
memories = [
|
|
473
|
+
RecallMemory(
|
|
474
|
+
blob_id=m["blob_id"],
|
|
475
|
+
text=m["text"],
|
|
476
|
+
distance=m["distance"],
|
|
477
|
+
)
|
|
478
|
+
for m in data.get("results", [])
|
|
479
|
+
]
|
|
480
|
+
return RecallResult(results=memories, total=data.get("total", len(memories)))
|
|
481
|
+
|
|
482
|
+
async def analyze(self, text: str, namespace: Optional[str] = None) -> AnalyzeResult:
|
|
483
|
+
"""Analyze conversation text and return as soon as facts are accepted.
|
|
484
|
+
|
|
485
|
+
Per PR #121: server extracts atomic facts synchronously via LLM, then
|
|
486
|
+
enqueues one background remember job per fact. Returns 202 with
|
|
487
|
+
``job_ids`` aligned to ``facts``.
|
|
488
|
+
|
|
489
|
+
Use :meth:`analyze_and_wait` to also wait for every fact to finish
|
|
490
|
+
persisting (poll all job_ids together).
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
text: Conversation text to analyze.
|
|
494
|
+
namespace: Override the default namespace.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
:class:`AnalyzeResult` with extracted ``facts`` + per-fact
|
|
498
|
+
``job_ids`` for downstream polling.
|
|
499
|
+
"""
|
|
500
|
+
data = await self._signed_request(
|
|
501
|
+
"POST",
|
|
502
|
+
"/api/analyze",
|
|
503
|
+
{"text": text, "namespace": namespace or self._namespace},
|
|
504
|
+
accepted_statuses=(200, 202),
|
|
505
|
+
)
|
|
506
|
+
# Backward-compat: older server shape returned `facts[].id` and
|
|
507
|
+
# `facts[].blob_id` directly. New async shape may omit `blob_id`
|
|
508
|
+
# at this point (set later by the worker) and add `job_ids`.
|
|
509
|
+
facts = [
|
|
510
|
+
AnalyzedFact(
|
|
511
|
+
text=f["text"],
|
|
512
|
+
id=f.get("id", ""),
|
|
513
|
+
blob_id=f.get("blob_id", ""),
|
|
514
|
+
)
|
|
515
|
+
for f in data.get("facts", [])
|
|
516
|
+
]
|
|
517
|
+
job_ids = list(data.get("job_ids", []))
|
|
518
|
+
fact_count = int(data.get("fact_count", data.get("total", len(facts))))
|
|
519
|
+
return AnalyzeResult(
|
|
520
|
+
facts=facts,
|
|
521
|
+
fact_count=fact_count,
|
|
522
|
+
job_ids=job_ids,
|
|
523
|
+
status=data.get("status", "pending"),
|
|
524
|
+
owner=data.get("owner", ""),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
async def analyze_and_wait(
|
|
528
|
+
self,
|
|
529
|
+
text: str,
|
|
530
|
+
namespace: Optional[str] = None,
|
|
531
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
532
|
+
) -> AnalyzeWaitResult:
|
|
533
|
+
"""Analyze + wait for every extracted fact to finish persisting.
|
|
534
|
+
|
|
535
|
+
Mirrors TS ``analyzeAndWait``: calls :meth:`analyze` then
|
|
536
|
+
:meth:`wait_for_remember_jobs` on the returned ``job_ids``. The
|
|
537
|
+
result combines the analyze fact list with the bulk-style settled
|
|
538
|
+
per-job results.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
accepted = await self.analyze(text, namespace)
|
|
542
|
+
completed = await self.wait_for_remember_jobs(accepted.job_ids, opts)
|
|
543
|
+
return AnalyzeWaitResult(
|
|
544
|
+
results=completed.results,
|
|
545
|
+
total=completed.total,
|
|
546
|
+
succeeded=completed.succeeded,
|
|
547
|
+
failed=completed.failed,
|
|
548
|
+
timed_out=completed.timed_out,
|
|
549
|
+
facts=accepted.facts,
|
|
550
|
+
owner=accepted.owner,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
async def embed(self, text: str) -> EmbedResult:
|
|
554
|
+
"""Compute the embedding vector for ``text`` without storing anything.
|
|
555
|
+
|
|
556
|
+
Calls ``POST /api/embed``. Useful for callers that want to do their
|
|
557
|
+
own indexing or vector math; for the standard "remember" flow, use
|
|
558
|
+
:meth:`remember` (server handles embed + encrypt + upload).
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
data = await self._signed_request(
|
|
562
|
+
"POST",
|
|
563
|
+
"/api/embed",
|
|
564
|
+
{"text": text},
|
|
565
|
+
)
|
|
566
|
+
return EmbedResult(vector=list(data.get("vector", [])))
|
|
567
|
+
|
|
568
|
+
async def ask(
|
|
569
|
+
self,
|
|
570
|
+
question: str,
|
|
571
|
+
limit: int = 5,
|
|
572
|
+
namespace: Optional[str] = None,
|
|
573
|
+
) -> AskResult:
|
|
574
|
+
"""Ask a question answered using your memories.
|
|
575
|
+
|
|
576
|
+
Server recalls relevant memories, injects them as context,
|
|
577
|
+
and calls an LLM to produce a personalized answer.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
question: The question to ask.
|
|
581
|
+
limit: Max memories to use as context (default: 5).
|
|
582
|
+
namespace: Override the default namespace.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
:class:`AskResult` with answer, memories_used count, and memories list.
|
|
586
|
+
"""
|
|
587
|
+
data = await self._signed_request("POST", "/api/ask", {
|
|
588
|
+
"question": question,
|
|
589
|
+
"limit": limit,
|
|
590
|
+
"namespace": namespace or self._namespace,
|
|
591
|
+
})
|
|
592
|
+
memories = [
|
|
593
|
+
AskMemory(blob_id=m["blob_id"], text=m["text"], distance=m["distance"])
|
|
594
|
+
for m in data.get("memories", [])
|
|
595
|
+
]
|
|
596
|
+
return AskResult(
|
|
597
|
+
answer=data["answer"],
|
|
598
|
+
memories_used=data["memories_used"],
|
|
599
|
+
memories=memories,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
async def restore(self, namespace: str, limit: int = 50) -> RestoreResult:
|
|
603
|
+
"""Restore a namespace.
|
|
604
|
+
|
|
605
|
+
Server downloads all blobs from Walrus, decrypts with delegate key,
|
|
606
|
+
re-embeds, and re-indexes.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
namespace: Namespace to restore.
|
|
610
|
+
limit: Max entries to restore (default: 50).
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
:class:`RestoreResult` with count of restored entries.
|
|
614
|
+
"""
|
|
615
|
+
data = await self._signed_request("POST", "/api/restore", {
|
|
616
|
+
"namespace": namespace,
|
|
617
|
+
"limit": limit,
|
|
618
|
+
})
|
|
619
|
+
return RestoreResult(
|
|
620
|
+
restored=data["restored"],
|
|
621
|
+
skipped=data["skipped"],
|
|
622
|
+
total=data["total"],
|
|
623
|
+
namespace=data["namespace"],
|
|
624
|
+
owner=data["owner"],
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
async def health(self) -> HealthResult:
|
|
628
|
+
"""Check server health. No authentication required.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
:class:`HealthResult` with status and version.
|
|
632
|
+
"""
|
|
633
|
+
response = await self._http.get(f"{self._server_url}/health")
|
|
634
|
+
if response.status_code != 200:
|
|
635
|
+
raise MemWalError(f"Health check failed: {response.status_code}")
|
|
636
|
+
data = response.json()
|
|
637
|
+
return HealthResult(status=data["status"], version=data["version"])
|
|
638
|
+
|
|
639
|
+
# ============================================================
|
|
640
|
+
# Manual API (user handles SEAL + embedding + Walrus)
|
|
641
|
+
# ============================================================
|
|
642
|
+
|
|
643
|
+
async def remember_manual(self, opts: RememberManualOptions) -> RememberManualResult:
|
|
644
|
+
"""Remember (manual mode).
|
|
645
|
+
|
|
646
|
+
User handles SEAL encrypt, embedding, and Walrus upload externally.
|
|
647
|
+
Server only stores the vector <-> blobId mapping.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
opts: :class:`RememberManualOptions` with blob_id, vector, and optional namespace.
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
:class:`RememberManualResult` with id, blob_id, owner, namespace.
|
|
654
|
+
"""
|
|
655
|
+
data = await self._signed_request("POST", "/api/remember/manual", {
|
|
656
|
+
"blob_id": opts.blob_id,
|
|
657
|
+
"vector": opts.vector,
|
|
658
|
+
"namespace": opts.namespace or self._namespace,
|
|
659
|
+
})
|
|
660
|
+
return RememberManualResult(
|
|
661
|
+
id=data["id"],
|
|
662
|
+
blob_id=data["blob_id"],
|
|
663
|
+
owner=data["owner"],
|
|
664
|
+
namespace=data["namespace"],
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
async def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
|
|
668
|
+
"""Recall (manual mode).
|
|
669
|
+
|
|
670
|
+
User provides a pre-computed query vector. Server returns matching
|
|
671
|
+
blobIds + distances. User then downloads from Walrus + SEAL decrypts.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
opts: :class:`RecallManualOptions` with vector, optional limit and namespace.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
:class:`RecallManualResult` with blob_id + distance pairs.
|
|
678
|
+
"""
|
|
679
|
+
data = await self._signed_request("POST", "/api/recall/manual", {
|
|
680
|
+
"vector": opts.vector,
|
|
681
|
+
"limit": opts.limit,
|
|
682
|
+
"namespace": opts.namespace or self._namespace,
|
|
683
|
+
})
|
|
684
|
+
hits = [
|
|
685
|
+
RecallManualHit(blob_id=h["blob_id"], distance=h["distance"])
|
|
686
|
+
for h in data.get("results", [])
|
|
687
|
+
]
|
|
688
|
+
return RecallManualResult(results=hits, total=data.get("total", len(hits)))
|
|
689
|
+
|
|
690
|
+
async def get_public_key_hex(self) -> str:
|
|
691
|
+
"""Get the Ed25519 public key as a hex string.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Public key hex string.
|
|
695
|
+
"""
|
|
696
|
+
return bytes_to_hex(bytes(self._signing_key.verify_key))
|
|
697
|
+
|
|
698
|
+
# ============================================================
|
|
699
|
+
# Internal: Signed HTTP Requests
|
|
700
|
+
# ============================================================
|
|
701
|
+
|
|
702
|
+
async def _signed_request(
|
|
703
|
+
self,
|
|
704
|
+
method: str,
|
|
705
|
+
path: str,
|
|
706
|
+
body: Dict[str, Any],
|
|
707
|
+
accepted_statuses: tuple = (200,),
|
|
708
|
+
) -> Dict[str, Any]:
|
|
709
|
+
"""Make a signed request to the server.
|
|
710
|
+
|
|
711
|
+
Signature format:
|
|
712
|
+
``{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}``
|
|
713
|
+
|
|
714
|
+
Headers sent:
|
|
715
|
+
- ``x-public-key``: Ed25519 public key hex
|
|
716
|
+
- ``x-signature``: Ed25519 signature hex
|
|
717
|
+
- ``x-timestamp``: Unix seconds string
|
|
718
|
+
- ``x-nonce``: UUID v4 replay-protection nonce
|
|
719
|
+
- ``x-delegate-key``: Private key hex
|
|
720
|
+
- ``x-account-id``: MemWalAccount object ID
|
|
721
|
+
- ``Content-Type``: application/json
|
|
722
|
+
"""
|
|
723
|
+
import uuid
|
|
724
|
+
|
|
725
|
+
timestamp = str(int(time.time()))
|
|
726
|
+
body_str = json.dumps(body, separators=(",", ":"))
|
|
727
|
+
body_hash = sha256_hex(body_str)
|
|
728
|
+
# MED-1 / LOW-23: nonce + account_id are part of the canonical signed
|
|
729
|
+
# message. Server rejects the request as "unsupported legacy SDK"
|
|
730
|
+
# (HTTP 426) if x-nonce is missing or not UUID-formatted.
|
|
731
|
+
nonce = str(uuid.uuid4())
|
|
732
|
+
|
|
733
|
+
message = build_signature_message(
|
|
734
|
+
timestamp,
|
|
735
|
+
method.upper(),
|
|
736
|
+
path,
|
|
737
|
+
body_hash,
|
|
738
|
+
nonce=nonce,
|
|
739
|
+
account_id=self._account_id,
|
|
740
|
+
)
|
|
741
|
+
signature_hex, public_key_hex = sign_message(message, self._signing_key)
|
|
742
|
+
|
|
743
|
+
url = f"{self._server_url}{path}"
|
|
744
|
+
headers = {
|
|
745
|
+
"Content-Type": "application/json",
|
|
746
|
+
"x-public-key": public_key_hex,
|
|
747
|
+
"x-signature": signature_hex,
|
|
748
|
+
"x-timestamp": timestamp,
|
|
749
|
+
"x-nonce": nonce,
|
|
750
|
+
"x-delegate-key": self._private_key_hex,
|
|
751
|
+
"x-account-id": self._account_id,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
response = await self._http.request(
|
|
755
|
+
method=method.upper(),
|
|
756
|
+
url=url,
|
|
757
|
+
headers=headers,
|
|
758
|
+
content=body_str,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
if response.status_code not in accepted_statuses:
|
|
762
|
+
err_text = response.text
|
|
763
|
+
raise _HttpStatusError(
|
|
764
|
+
status=response.status_code,
|
|
765
|
+
body=err_text,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
return response.json()
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
class MemWalError(Exception):
|
|
772
|
+
"""Exception raised for MemWal API errors."""
|
|
773
|
+
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
class _HttpStatusError(MemWalError):
|
|
778
|
+
"""Internal: raised when an HTTP response status is not in ``accepted_statuses``.
|
|
779
|
+
|
|
780
|
+
Carries ``.status`` so polling loops can decide whether to retry
|
|
781
|
+
(transient: 0/429/5xx) or surface (terminal: 4xx other than 404 when
|
|
782
|
+
explicitly accepted).
|
|
783
|
+
"""
|
|
784
|
+
|
|
785
|
+
def __init__(self, status: int, body: str) -> None:
|
|
786
|
+
super().__init__(f"MemWal API error ({status}): {body}")
|
|
787
|
+
self.status = status
|
|
788
|
+
self.body = body
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
class MemWalRememberJobNotFound(MemWalError):
|
|
792
|
+
"""The polled job_id does not exist or is not owned by the caller."""
|
|
793
|
+
|
|
794
|
+
def __init__(self, job_id: str) -> None:
|
|
795
|
+
super().__init__(f"remember job not found: {job_id}")
|
|
796
|
+
self.status = 404
|
|
797
|
+
self.job_id = job_id
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class MemWalRememberJobFailed(MemWalError):
|
|
801
|
+
"""The async remember job reached terminal status=failed."""
|
|
802
|
+
|
|
803
|
+
def __init__(self, job_id: str, error: str) -> None:
|
|
804
|
+
super().__init__(f"remember job failed: {error}")
|
|
805
|
+
self.status = 500
|
|
806
|
+
self.job_id = job_id
|
|
807
|
+
self.error = error
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class MemWalRememberJobTimeout(MemWalError):
|
|
811
|
+
"""Polling loop exceeded the configured timeout."""
|
|
812
|
+
|
|
813
|
+
def __init__(self, job_id: str, timeout_ms: int) -> None:
|
|
814
|
+
super().__init__(
|
|
815
|
+
f"remember job timed out after {timeout_ms}ms (job_id={job_id})"
|
|
816
|
+
)
|
|
817
|
+
self.status = 504
|
|
818
|
+
self.job_id = job_id
|
|
819
|
+
self.timeout_ms = timeout_ms
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
class MemWalSync:
|
|
823
|
+
"""Synchronous wrapper around the async :class:`MemWal` client.
|
|
824
|
+
|
|
825
|
+
Provides the same API surface but runs everything through ``asyncio.run()``.
|
|
826
|
+
Useful for scripts, notebooks, and non-async applications.
|
|
827
|
+
|
|
828
|
+
Example::
|
|
829
|
+
|
|
830
|
+
from memwal import MemWalSync
|
|
831
|
+
|
|
832
|
+
client = MemWalSync.create(key="...", account_id="0x...")
|
|
833
|
+
result = client.remember("I love coffee")
|
|
834
|
+
matches = client.recall("coffee preferences")
|
|
835
|
+
"""
|
|
836
|
+
|
|
837
|
+
def __init__(self, inner: MemWal) -> None:
|
|
838
|
+
self._inner = inner
|
|
839
|
+
|
|
840
|
+
@classmethod
|
|
841
|
+
def create(
|
|
842
|
+
cls,
|
|
843
|
+
key: str,
|
|
844
|
+
account_id: str,
|
|
845
|
+
server_url: str = "https://relayer.memwal.ai",
|
|
846
|
+
namespace: str = "default",
|
|
847
|
+
) -> "MemWalSync":
|
|
848
|
+
"""Create a synchronous MemWal client.
|
|
849
|
+
|
|
850
|
+
Same parameters as :meth:`MemWal.create`.
|
|
851
|
+
"""
|
|
852
|
+
inner = MemWal.create(
|
|
853
|
+
key=key,
|
|
854
|
+
account_id=account_id,
|
|
855
|
+
server_url=server_url,
|
|
856
|
+
namespace=namespace,
|
|
857
|
+
)
|
|
858
|
+
return cls(inner)
|
|
859
|
+
|
|
860
|
+
def _run(self, coro: Any) -> Any:
|
|
861
|
+
try:
|
|
862
|
+
loop = asyncio.get_running_loop()
|
|
863
|
+
except RuntimeError:
|
|
864
|
+
loop = None
|
|
865
|
+
|
|
866
|
+
if loop is not None and loop.is_running():
|
|
867
|
+
# Already inside an event loop (e.g. Jupyter).
|
|
868
|
+
# Create a new loop in a thread.
|
|
869
|
+
import concurrent.futures
|
|
870
|
+
|
|
871
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
872
|
+
return pool.submit(asyncio.run, coro).result()
|
|
873
|
+
else:
|
|
874
|
+
# Reset the httpx client before each asyncio.run() so it is
|
|
875
|
+
# recreated fresh inside the new event loop. Without this,
|
|
876
|
+
# reusing a MemWalSync instance across multiple calls raises
|
|
877
|
+
# "RuntimeError: Event loop is closed" because the client's
|
|
878
|
+
# transport is still bound to the previous (now-closed) loop.
|
|
879
|
+
self._inner._client = None
|
|
880
|
+
return asyncio.run(coro)
|
|
881
|
+
|
|
882
|
+
def remember(
|
|
883
|
+
self, text: str, namespace: Optional[str] = None
|
|
884
|
+
) -> RememberAcceptedResult:
|
|
885
|
+
"""Synchronous version of :meth:`MemWal.remember` (async accept)."""
|
|
886
|
+
return self._run(self._inner.remember(text, namespace))
|
|
887
|
+
|
|
888
|
+
# Alias for parity with TS SDK ``rememberAsync``.
|
|
889
|
+
def remember_async(
|
|
890
|
+
self, text: str, namespace: Optional[str] = None
|
|
891
|
+
) -> RememberAcceptedResult:
|
|
892
|
+
return self._run(self._inner.remember_async(text, namespace))
|
|
893
|
+
|
|
894
|
+
def wait_for_remember_job(
|
|
895
|
+
self,
|
|
896
|
+
job_id: str,
|
|
897
|
+
poll_interval_ms: int = 1500,
|
|
898
|
+
timeout_ms: int = 60_000,
|
|
899
|
+
) -> RememberResult:
|
|
900
|
+
"""Synchronous version of :meth:`MemWal.wait_for_remember_job`."""
|
|
901
|
+
return self._run(
|
|
902
|
+
self._inner.wait_for_remember_job(
|
|
903
|
+
job_id,
|
|
904
|
+
poll_interval_ms=poll_interval_ms,
|
|
905
|
+
timeout_ms=timeout_ms,
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def remember_and_wait(
|
|
910
|
+
self,
|
|
911
|
+
text: str,
|
|
912
|
+
namespace: Optional[str] = None,
|
|
913
|
+
poll_interval_ms: int = 1500,
|
|
914
|
+
timeout_ms: int = 60_000,
|
|
915
|
+
) -> RememberResult:
|
|
916
|
+
"""Synchronous version of :meth:`MemWal.remember_and_wait`."""
|
|
917
|
+
return self._run(
|
|
918
|
+
self._inner.remember_and_wait(
|
|
919
|
+
text,
|
|
920
|
+
namespace,
|
|
921
|
+
poll_interval_ms=poll_interval_ms,
|
|
922
|
+
timeout_ms=timeout_ms,
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
def remember_bulk(
|
|
927
|
+
self, items: Sequence[RememberBulkItem]
|
|
928
|
+
) -> RememberBulkAcceptedResult:
|
|
929
|
+
"""Synchronous version of :meth:`MemWal.remember_bulk`."""
|
|
930
|
+
return self._run(self._inner.remember_bulk(items))
|
|
931
|
+
|
|
932
|
+
def remember_bulk_async(
|
|
933
|
+
self, items: Sequence[RememberBulkItem]
|
|
934
|
+
) -> RememberBulkAcceptedResult:
|
|
935
|
+
return self._run(self._inner.remember_bulk_async(items))
|
|
936
|
+
|
|
937
|
+
def get_remember_bulk_status(
|
|
938
|
+
self, job_ids: Sequence[str]
|
|
939
|
+
) -> RememberBulkStatusResult:
|
|
940
|
+
"""Synchronous version of :meth:`MemWal.get_remember_bulk_status`."""
|
|
941
|
+
return self._run(self._inner.get_remember_bulk_status(job_ids))
|
|
942
|
+
|
|
943
|
+
def wait_for_remember_jobs(
|
|
944
|
+
self,
|
|
945
|
+
job_ids: Sequence[str],
|
|
946
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
947
|
+
) -> RememberBulkResult:
|
|
948
|
+
"""Synchronous version of :meth:`MemWal.wait_for_remember_jobs`."""
|
|
949
|
+
return self._run(self._inner.wait_for_remember_jobs(job_ids, opts))
|
|
950
|
+
|
|
951
|
+
def remember_bulk_and_wait(
|
|
952
|
+
self,
|
|
953
|
+
items: Sequence[RememberBulkItem],
|
|
954
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
955
|
+
) -> RememberBulkResult:
|
|
956
|
+
"""Synchronous version of :meth:`MemWal.remember_bulk_and_wait`."""
|
|
957
|
+
return self._run(self._inner.remember_bulk_and_wait(items, opts))
|
|
958
|
+
|
|
959
|
+
def recall(
|
|
960
|
+
self, query: str, limit: int = 10, namespace: Optional[str] = None
|
|
961
|
+
) -> RecallResult:
|
|
962
|
+
"""Synchronous version of :meth:`MemWal.recall`."""
|
|
963
|
+
return self._run(self._inner.recall(query, limit, namespace))
|
|
964
|
+
|
|
965
|
+
def analyze(self, text: str, namespace: Optional[str] = None) -> AnalyzeResult:
|
|
966
|
+
"""Synchronous version of :meth:`MemWal.analyze`."""
|
|
967
|
+
return self._run(self._inner.analyze(text, namespace))
|
|
968
|
+
|
|
969
|
+
def analyze_and_wait(
|
|
970
|
+
self,
|
|
971
|
+
text: str,
|
|
972
|
+
namespace: Optional[str] = None,
|
|
973
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
974
|
+
) -> AnalyzeWaitResult:
|
|
975
|
+
"""Synchronous version of :meth:`MemWal.analyze_and_wait`."""
|
|
976
|
+
return self._run(self._inner.analyze_and_wait(text, namespace, opts))
|
|
977
|
+
|
|
978
|
+
def embed(self, text: str) -> EmbedResult:
|
|
979
|
+
"""Synchronous version of :meth:`MemWal.embed`."""
|
|
980
|
+
return self._run(self._inner.embed(text))
|
|
981
|
+
|
|
982
|
+
def ask(self, question: str, limit: int = 5, namespace: Optional[str] = None) -> AskResult:
|
|
983
|
+
"""Synchronous version of :meth:`MemWal.ask`."""
|
|
984
|
+
return self._run(self._inner.ask(question, limit, namespace))
|
|
985
|
+
|
|
986
|
+
def restore(self, namespace: str, limit: int = 50) -> RestoreResult:
|
|
987
|
+
"""Synchronous version of :meth:`MemWal.restore`."""
|
|
988
|
+
return self._run(self._inner.restore(namespace, limit))
|
|
989
|
+
|
|
990
|
+
def health(self) -> HealthResult:
|
|
991
|
+
"""Synchronous version of :meth:`MemWal.health`."""
|
|
992
|
+
return self._run(self._inner.health())
|
|
993
|
+
|
|
994
|
+
def remember_manual(self, opts: RememberManualOptions) -> RememberManualResult:
|
|
995
|
+
"""Synchronous version of :meth:`MemWal.remember_manual`."""
|
|
996
|
+
return self._run(self._inner.remember_manual(opts))
|
|
997
|
+
|
|
998
|
+
def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
|
|
999
|
+
"""Synchronous version of :meth:`MemWal.recall_manual`."""
|
|
1000
|
+
return self._run(self._inner.recall_manual(opts))
|
|
1001
|
+
|
|
1002
|
+
def get_public_key_hex(self) -> str:
|
|
1003
|
+
"""Synchronous version of :meth:`MemWal.get_public_key_hex`."""
|
|
1004
|
+
return self._run(self._inner.get_public_key_hex())
|
|
1005
|
+
|
|
1006
|
+
def close(self) -> None:
|
|
1007
|
+
"""Close the underlying HTTP client."""
|
|
1008
|
+
self._run(self._inner.close())
|
|
1009
|
+
|
|
1010
|
+
def __enter__(self) -> "MemWalSync":
|
|
1011
|
+
return self
|
|
1012
|
+
|
|
1013
|
+
def __exit__(self, *exc: Any) -> None:
|
|
1014
|
+
self.close()
|