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/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()