memwal 0.1.0.dev1__tar.gz → 0.1.0.dev3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memwal
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev3
4
4
  Summary: Python SDK for MemWal — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memwal.ai
6
6
  Project-URL: Documentation, https://docs.memwal.ai
@@ -130,6 +130,32 @@ async with MemWal.create(
130
130
  await memwal.remember("I prefer dark mode")
131
131
  ```
132
132
 
133
+ ## Environment Presets
134
+
135
+ Instead of hardcoding a relayer URL, pass `env` to target a hosted relayer.
136
+ Same shorthand as the TypeScript SDK and MCP package.
137
+
138
+ ```python
139
+ from memwal import MemWal
140
+
141
+ memwal = MemWal.create(
142
+ key=os.environ["MEMWAL_KEY"],
143
+ account_id=os.environ["MEMWAL_ACCOUNT_ID"],
144
+ env="prod", # prod | dev | staging | local
145
+ )
146
+ ```
147
+
148
+ | `env` | Relayer URL |
149
+ |-------|-------------|
150
+ | `prod` | `https://relayer.memwal.ai` |
151
+ | `dev` | `https://relayer.dev.memwal.ai` |
152
+ | `staging` | `https://relayer.staging.memwal.ai` |
153
+ | `local` | `http://127.0.0.1:8000` |
154
+
155
+ Precedence: an explicit non-default **`server_url` wins over `env`**, which wins
156
+ over the default. An unknown preset raises `ValueError`. `env` is also accepted
157
+ by `MemWalSync.create`, `with_memwal_langchain`, and `with_memwal_openai`.
158
+
133
159
  ## AI Middleware
134
160
 
135
161
  ### LangChain
@@ -201,10 +227,10 @@ Create a new async client.
201
227
  Every request is signed with Ed25519:
202
228
 
203
229
  ```
204
- message = f"{timestamp}.{method}.{path}.{sha256(body)}.{nonce}.{account_id}"
230
+ message = f"{timestamp}.{method}.{path}.{sha256(body)}"
205
231
  ```
206
232
 
207
- Headers sent: `x-public-key`, `x-signature`, `x-timestamp`, `x-nonce`, `x-delegate-key`, `x-account-id`.
233
+ Headers sent: `x-public-key`, `x-signature`, `x-timestamp`, `x-delegate-key`, `x-account-id`.
208
234
 
209
235
  ## License
210
236
 
@@ -91,6 +91,32 @@ async with MemWal.create(
91
91
  await memwal.remember("I prefer dark mode")
92
92
  ```
93
93
 
94
+ ## Environment Presets
95
+
96
+ Instead of hardcoding a relayer URL, pass `env` to target a hosted relayer.
97
+ Same shorthand as the TypeScript SDK and MCP package.
98
+
99
+ ```python
100
+ from memwal import MemWal
101
+
102
+ memwal = MemWal.create(
103
+ key=os.environ["MEMWAL_KEY"],
104
+ account_id=os.environ["MEMWAL_ACCOUNT_ID"],
105
+ env="prod", # prod | dev | staging | local
106
+ )
107
+ ```
108
+
109
+ | `env` | Relayer URL |
110
+ |-------|-------------|
111
+ | `prod` | `https://relayer.memwal.ai` |
112
+ | `dev` | `https://relayer.dev.memwal.ai` |
113
+ | `staging` | `https://relayer.staging.memwal.ai` |
114
+ | `local` | `http://127.0.0.1:8000` |
115
+
116
+ Precedence: an explicit non-default **`server_url` wins over `env`**, which wins
117
+ over the default. An unknown preset raises `ValueError`. `env` is also accepted
118
+ by `MemWalSync.create`, `with_memwal_langchain`, and `with_memwal_openai`.
119
+
94
120
  ## AI Middleware
95
121
 
96
122
  ### LangChain
@@ -162,10 +188,10 @@ Create a new async client.
162
188
  Every request is signed with Ed25519:
163
189
 
164
190
  ```
165
- message = f"{timestamp}.{method}.{path}.{sha256(body)}.{nonce}.{account_id}"
191
+ message = f"{timestamp}.{method}.{path}.{sha256(body)}"
166
192
  ```
167
193
 
168
- Headers sent: `x-public-key`, `x-signature`, `x-timestamp`, `x-nonce`, `x-delegate-key`, `x-account-id`.
194
+ Headers sent: `x-public-key`, `x-signature`, `x-timestamp`, `x-delegate-key`, `x-account-id`.
169
195
 
170
196
  ## License
171
197
 
@@ -25,6 +25,7 @@ import os
25
25
  import sys
26
26
  import time
27
27
  from pathlib import Path
28
+ from typing import Optional
28
29
 
29
30
 
30
31
  def _load_env() -> None:
@@ -49,6 +50,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
49
50
 
50
51
  from memwal import ( # noqa: E402
51
52
  MemWal,
53
+ MemWalRememberJobFailed,
52
54
  RememberBulkItem,
53
55
  RememberBulkOptions,
54
56
  )
@@ -33,6 +33,7 @@ from .client import (
33
33
  from .middleware import with_memwal_langchain, with_memwal_openai
34
34
  from .utils import delegate_key_to_sui_address, delegate_key_to_public_key
35
35
  from .types import (
36
+ ENV_PRESETS,
36
37
  AnalyzedFact,
37
38
  AnalyzeResult,
38
39
  AnalyzeWaitResult,
@@ -81,6 +82,7 @@ __all__ = [
81
82
  "withMemWal",
82
83
  # Types
83
84
  "MemWalConfig",
85
+ "ENV_PRESETS",
84
86
  "AskMemory",
85
87
  "AskResult",
86
88
  "RememberResult",
@@ -108,4 +110,4 @@ __all__ = [
108
110
  "RecallManualResult",
109
111
  ]
110
112
 
111
- __version__ = "0.1.0.dev1"
113
+ __version__ = "0.1.0.dev3"
@@ -26,12 +26,14 @@ Example::
26
26
  from __future__ import annotations
27
27
 
28
28
  import asyncio
29
+ import base64
29
30
  import json
30
31
  import random
31
32
  import time
32
- from typing import Any, Dict, List, Optional, Sequence, TypeVar
33
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar
33
34
 
34
35
  import httpx
36
+ import nacl.signing
35
37
 
36
38
  from .types import (
37
39
  AnalyzedFact,
@@ -55,20 +57,27 @@ from .types import (
55
57
  RememberBulkResult,
56
58
  RememberBulkStatusItem,
57
59
  RememberBulkStatusResult,
60
+ RememberJobStatus,
58
61
  RememberManualOptions,
59
62
  RememberManualResult,
60
63
  RememberResult,
61
64
  RestoreResult,
62
65
  )
63
66
  from .utils import (
67
+ build_seal_session_personal_message,
64
68
  build_signature_message,
65
69
  build_signing_key,
66
70
  bytes_to_hex,
71
+ delegate_key_to_sui_address,
72
+ encode_sui_private_key,
67
73
  sha256_hex,
68
74
  sign_message,
75
+ sign_sui_personal_message,
69
76
  )
70
77
 
71
78
  T = TypeVar("T")
79
+ SEAL_SESSION_TTL_MIN = 5
80
+ SEAL_SESSION_SAFETY_MARGIN_MS = 30_000
72
81
 
73
82
 
74
83
  # ============================================================
@@ -122,22 +131,30 @@ class MemWal:
122
131
  self._server_url = config.server_url.rstrip("/")
123
132
  self._namespace = config.namespace
124
133
  self._client: Optional[httpx.AsyncClient] = None
134
+ self._server_config: Optional[Dict[str, str]] = None
135
+ self._session_cache: Optional[Tuple[str, int]] = None
136
+ self._session_build_task: Optional[asyncio.Task[str]] = None
125
137
 
126
138
  @classmethod
127
139
  def create(
128
140
  cls,
129
141
  key: str,
130
142
  account_id: str,
131
- server_url: str = "https://relayer.memwal.ai",
143
+ server_url: str = "http://localhost:8000",
132
144
  namespace: str = "default",
145
+ env: Optional[str] = None,
133
146
  ) -> "MemWal":
134
147
  """Create a new MemWal client instance.
135
148
 
136
149
  Args:
137
150
  key: Ed25519 private key hex string (the delegate key).
138
151
  account_id: MemWalAccount object ID on Sui.
139
- server_url: Server URL (default: ``https://relayer.memwal.ai``).
152
+ server_url: Server URL (default: ``http://localhost:8000``).
140
153
  namespace: Default namespace for memory isolation (default: ``"default"``).
154
+ env: Optional relayer preset — ``"prod"``, ``"dev"``, ``"staging"``,
155
+ or ``"local"``. Resolves ``server_url`` to the matching hosted
156
+ relayer unless an explicit non-default ``server_url`` is given.
157
+ Precedence: explicit ``server_url`` > ``env`` > default.
141
158
 
142
159
  Returns:
143
160
  A configured :class:`MemWal` instance.
@@ -147,6 +164,7 @@ class MemWal:
147
164
  account_id=account_id,
148
165
  server_url=server_url,
149
166
  namespace=namespace,
167
+ env=env,
150
168
  )
151
169
  return cls(config)
152
170
 
@@ -652,11 +670,16 @@ class MemWal:
652
670
  Returns:
653
671
  :class:`RememberManualResult` with id, blob_id, owner, namespace.
654
672
  """
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
- })
673
+ data = await self._signed_request(
674
+ "POST",
675
+ "/api/remember/manual",
676
+ {
677
+ "blob_id": opts.blob_id,
678
+ "vector": opts.vector,
679
+ "namespace": opts.namespace or self._namespace,
680
+ },
681
+ include_seal_session=False,
682
+ )
660
683
  return RememberManualResult(
661
684
  id=data["id"],
662
685
  blob_id=data["blob_id"],
@@ -676,11 +699,16 @@ class MemWal:
676
699
  Returns:
677
700
  :class:`RecallManualResult` with blob_id + distance pairs.
678
701
  """
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
- })
702
+ data = await self._signed_request(
703
+ "POST",
704
+ "/api/recall/manual",
705
+ {
706
+ "vector": opts.vector,
707
+ "limit": opts.limit,
708
+ "namespace": opts.namespace or self._namespace,
709
+ },
710
+ include_seal_session=False,
711
+ )
684
712
  hits = [
685
713
  RecallManualHit(blob_id=h["blob_id"], distance=h["distance"])
686
714
  for h in data.get("results", [])
@@ -699,31 +727,141 @@ class MemWal:
699
727
  # Internal: Signed HTTP Requests
700
728
  # ============================================================
701
729
 
730
+ async def _fetch_server_config(self) -> Dict[str, str]:
731
+ if self._server_config is not None:
732
+ return self._server_config
733
+
734
+ response = await self._http.get(f"{self._server_url}/config")
735
+ if response.status_code != 200:
736
+ raise MemWalError(f"GET /config returned {response.status_code}")
737
+
738
+ data = response.json()
739
+ package_id = data.get("packageId")
740
+ network = data.get("network")
741
+ sui_rpc_url = data.get("suiRpcUrl")
742
+ if not package_id or not network or not sui_rpc_url:
743
+ raise MemWalError("GET /config response missing packageId / network / suiRpcUrl")
744
+
745
+ self._server_config = {
746
+ "packageId": package_id,
747
+ "network": network,
748
+ "suiRpcUrl": sui_rpc_url,
749
+ }
750
+ return self._server_config
751
+
752
+ async def _assert_first_package_version(self, sui_rpc_url: str, package_id: str) -> None:
753
+ response = await self._http.post(
754
+ sui_rpc_url,
755
+ json={
756
+ "jsonrpc": "2.0",
757
+ "id": 1,
758
+ "method": "sui_getObject",
759
+ "params": [package_id, {"showBcs": False, "showContent": False, "showType": False}],
760
+ },
761
+ )
762
+ if response.status_code != 200:
763
+ raise MemWalError(f"sui_getObject returned {response.status_code}")
764
+
765
+ body = response.json()
766
+ result = body.get("result", {})
767
+ version = None
768
+ if isinstance(result, dict):
769
+ data = result.get("data")
770
+ if isinstance(data, dict):
771
+ version = data.get("version")
772
+ if version is None:
773
+ obj = result.get("object")
774
+ if isinstance(obj, dict):
775
+ version = obj.get("version")
776
+ if str(version) != "1":
777
+ raise MemWalError(
778
+ f"SEAL package {package_id} must be at version 1 to build x-seal-session, got {version!r}"
779
+ )
780
+
781
+ async def _build_seal_session_inner(self) -> str:
782
+ cfg = await self._fetch_server_config()
783
+ await self._assert_first_package_version(cfg["suiRpcUrl"], cfg["packageId"])
784
+
785
+ session_signing_key = nacl.signing.SigningKey.generate()
786
+ session_public_key = bytes(session_signing_key.verify_key)
787
+ creation_time_ms = int(time.time() * 1000)
788
+ personal_message = build_seal_session_personal_message(
789
+ package_id=cfg["packageId"],
790
+ ttl_min=SEAL_SESSION_TTL_MIN,
791
+ creation_time_ms=creation_time_ms,
792
+ session_public_key_bytes=session_public_key,
793
+ )
794
+ personal_message_signature = sign_sui_personal_message(
795
+ personal_message,
796
+ self._signing_key,
797
+ )
798
+
799
+ json_str = json.dumps(
800
+ {
801
+ "address": delegate_key_to_sui_address(self._private_key_hex),
802
+ "packageId": cfg["packageId"],
803
+ "mvrName": None,
804
+ "creationTimeMs": creation_time_ms,
805
+ "ttlMin": SEAL_SESSION_TTL_MIN,
806
+ "personalMessageSignature": personal_message_signature,
807
+ "sessionKey": encode_sui_private_key(bytes(session_signing_key)),
808
+ },
809
+ separators=(",", ":"),
810
+ )
811
+ session_bytes = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
812
+ self._session_cache = (
813
+ session_bytes,
814
+ int(time.time() * 1000) + SEAL_SESSION_TTL_MIN * 60_000 - SEAL_SESSION_SAFETY_MARGIN_MS,
815
+ )
816
+ return session_bytes
817
+
818
+ async def _build_seal_session(self) -> str:
819
+ now_ms = int(time.time() * 1000)
820
+ if self._session_cache is not None:
821
+ cached_bytes, expires_at_ms = self._session_cache
822
+ if now_ms < expires_at_ms:
823
+ return cached_bytes
824
+
825
+ if self._session_build_task is not None:
826
+ return await self._session_build_task
827
+
828
+ self._session_build_task = asyncio.create_task(self._build_seal_session_inner())
829
+ try:
830
+ return await self._session_build_task
831
+ finally:
832
+ self._session_build_task = None
833
+
702
834
  async def _signed_request(
703
835
  self,
704
836
  method: str,
705
837
  path: str,
706
838
  body: Dict[str, Any],
707
839
  accepted_statuses: tuple = (200,),
840
+ include_seal_session: bool = True,
708
841
  ) -> Dict[str, Any]:
709
842
  """Make a signed request to the server.
710
843
 
711
844
  Signature format:
712
845
  ``{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}``
713
846
 
847
+ For ``GET`` requests the canonical body string is the empty string,
848
+ and no HTTP request body is sent. This keeps the signed payload hash
849
+ byte-compatible with the TypeScript SDK and with intermediaries that
850
+ strip ``GET`` bodies on the wire.
851
+
714
852
  Headers sent:
715
853
  - ``x-public-key``: Ed25519 public key hex
716
854
  - ``x-signature``: Ed25519 signature hex
717
855
  - ``x-timestamp``: Unix seconds string
718
- - ``x-nonce``: UUID v4 replay-protection nonce
719
- - ``x-delegate-key``: Private key hex
856
+ - ``x-seal-session``: Base64-encoded exported session envelope
720
857
  - ``x-account-id``: MemWalAccount object ID
721
858
  - ``Content-Type``: application/json
722
859
  """
723
860
  import uuid
724
861
 
862
+ method_upper = method.upper()
725
863
  timestamp = str(int(time.time()))
726
- body_str = json.dumps(body, separators=(",", ":"))
864
+ body_str = "" if method_upper == "GET" else json.dumps(body, separators=(",", ":"))
727
865
  body_hash = sha256_hex(body_str)
728
866
  # MED-1 / LOW-23: nonce + account_id are part of the canonical signed
729
867
  # message. Server rejects the request as "unsupported legacy SDK"
@@ -732,7 +870,7 @@ class MemWal:
732
870
 
733
871
  message = build_signature_message(
734
872
  timestamp,
735
- method.upper(),
873
+ method_upper,
736
874
  path,
737
875
  body_hash,
738
876
  nonce=nonce,
@@ -747,15 +885,16 @@ class MemWal:
747
885
  "x-signature": signature_hex,
748
886
  "x-timestamp": timestamp,
749
887
  "x-nonce": nonce,
750
- "x-delegate-key": self._private_key_hex,
751
888
  "x-account-id": self._account_id,
752
889
  }
890
+ if include_seal_session:
891
+ headers["x-seal-session"] = await self._build_seal_session()
753
892
 
754
893
  response = await self._http.request(
755
- method=method.upper(),
894
+ method=method_upper,
756
895
  url=url,
757
896
  headers=headers,
758
- content=body_str,
897
+ content=None if method_upper == "GET" else body_str,
759
898
  )
760
899
 
761
900
  if response.status_code not in accepted_statuses:
@@ -842,22 +981,27 @@ class MemWalSync:
842
981
  cls,
843
982
  key: str,
844
983
  account_id: str,
845
- server_url: str = "https://relayer.memwal.ai",
984
+ server_url: str = "http://localhost:8000",
846
985
  namespace: str = "default",
986
+ env: Optional[str] = None,
847
987
  ) -> "MemWalSync":
848
988
  """Create a synchronous MemWal client.
849
989
 
850
- Same parameters as :meth:`MemWal.create`.
990
+ Same parameters as :meth:`MemWal.create` (including the ``env``
991
+ relayer preset).
851
992
  """
852
993
  inner = MemWal.create(
853
994
  key=key,
854
995
  account_id=account_id,
855
996
  server_url=server_url,
856
997
  namespace=namespace,
998
+ env=env,
857
999
  )
858
1000
  return cls(inner)
859
1001
 
860
1002
  def _run(self, coro: Any) -> Any:
1003
+ import asyncio
1004
+
861
1005
  try:
862
1006
  loop = asyncio.get_running_loop()
863
1007
  except RuntimeError:
@@ -137,12 +137,13 @@ def with_memwal_langchain(
137
137
  llm: "BaseChatModel",
138
138
  key: str,
139
139
  account_id: str,
140
- server_url: str = "https://relayer.memwal.ai",
140
+ server_url: str = "http://localhost:8000",
141
141
  namespace: str = "default",
142
142
  max_memories: int = 5,
143
143
  auto_save: bool = True,
144
144
  min_relevance: float = 0.3,
145
145
  debug: bool = False,
146
+ env: Optional[str] = None,
146
147
  ) -> "BaseChatModel":
147
148
  """Wrap a LangChain ``BaseChatModel`` with MemWal memory management.
148
149
 
@@ -163,6 +164,8 @@ def with_memwal_langchain(
163
164
  auto_save: Auto-save new facts from conversation.
164
165
  min_relevance: Minimum similarity score (0-1) to include a memory.
165
166
  debug: Enable debug logging.
167
+ env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
168
+ ``"local"``). Same precedence as :meth:`MemWal.create`.
166
169
 
167
170
  Returns:
168
171
  A wrapped ``BaseChatModel`` that automatically uses MemWal memory.
@@ -181,6 +184,7 @@ def with_memwal_langchain(
181
184
  account_id=account_id,
182
185
  server_url=server_url,
183
186
  namespace=namespace,
187
+ env=env,
184
188
  )
185
189
 
186
190
  log = logger.debug if not debug else logger.warning
@@ -254,6 +258,8 @@ def with_memwal_langchain(
254
258
  messages: List[List[BaseMessage]], *args: Any, **kwargs: Any
255
259
  ) -> ChatResult:
256
260
  # For sync generate, we inject memories synchronously via asyncio.run
261
+ import asyncio
262
+
257
263
  enriched = []
258
264
  for msg_list in messages:
259
265
  try:
@@ -290,12 +296,13 @@ def with_memwal_openai(
290
296
  client: Any,
291
297
  key: str,
292
298
  account_id: str,
293
- server_url: str = "https://relayer.memwal.ai",
299
+ server_url: str = "http://localhost:8000",
294
300
  namespace: str = "default",
295
301
  max_memories: int = 5,
296
302
  auto_save: bool = True,
297
303
  min_relevance: float = 0.3,
298
304
  debug: bool = False,
305
+ env: Optional[str] = None,
299
306
  ) -> Any:
300
307
  """Wrap an OpenAI client with MemWal memory management.
301
308
 
@@ -318,6 +325,8 @@ def with_memwal_openai(
318
325
  auto_save: Auto-save new facts from conversation.
319
326
  min_relevance: Minimum similarity score (0-1) to include a memory.
320
327
  debug: Enable debug logging.
328
+ env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
329
+ ``"local"``). Same precedence as :meth:`MemWal.create`.
321
330
 
322
331
  Returns:
323
332
  The same client, with ``chat.completions.create`` wrapped to use MemWal.
@@ -327,6 +336,7 @@ def with_memwal_openai(
327
336
  account_id=account_id,
328
337
  server_url=server_url,
329
338
  namespace=namespace,
339
+ env=env,
330
340
  )
331
341
 
332
342
  log = logger.debug if not debug else logger.warning
@@ -408,6 +418,8 @@ def _wrap_sync_openai(
408
418
  original_create = client.chat.completions.create
409
419
 
410
420
  def patched_create(*args: Any, **kwargs: Any) -> Any:
421
+ import asyncio
422
+
411
423
  messages = kwargs.get("messages") or (args[0] if args else None)
412
424
  if messages is None:
413
425
  return original_create(*args, **kwargs)
@@ -15,6 +15,21 @@ from typing import List, Optional
15
15
  # Config
16
16
  # ============================================================
17
17
 
18
+ #: Default ``server_url`` when neither an explicit URL nor an ``env`` preset
19
+ #: is supplied. Kept as a module constant so ``__post_init__`` can tell an
20
+ #: untouched default apart from an explicitly-passed custom URL.
21
+ DEFAULT_SERVER_URL = "http://localhost:8000"
22
+
23
+ #: Named relayer environments. Mirrors the TypeScript SDK / MCP package
24
+ #: ``--prod`` / ``--dev`` / ``--staging`` / ``--local`` presets so the same
25
+ #: shorthand works across every MemWal client.
26
+ ENV_PRESETS = {
27
+ "prod": "https://relayer.memwal.ai",
28
+ "dev": "https://relayer.dev.memwal.ai",
29
+ "staging": "https://relayer.staging.memwal.ai",
30
+ "local": "http://127.0.0.1:8000",
31
+ }
32
+
18
33
 
19
34
  @dataclass
20
35
  class MemWalConfig:
@@ -23,14 +38,33 @@ class MemWalConfig:
23
38
  Attributes:
24
39
  key: Ed25519 private key (hex string). This is the delegate key from app.memwal.com.
25
40
  account_id: MemWalAccount object ID on Sui.
26
- server_url: Server URL (default: https://relayer.memwal.ai).
41
+ server_url: Server URL (default: http://localhost:8000). An explicit
42
+ non-default value always wins over ``env``.
27
43
  namespace: Default namespace for memory isolation (default: "default").
44
+ env: Optional relayer preset — one of ``"prod"``, ``"dev"``,
45
+ ``"staging"``, ``"local"``. Resolves ``server_url`` to the matching
46
+ hosted relayer when ``server_url`` is left at its default.
47
+ Precedence: explicit ``server_url`` > ``env`` > default.
28
48
  """
29
49
 
30
50
  key: str
31
51
  account_id: str
32
- server_url: str = "https://relayer.memwal.ai"
52
+ server_url: str = DEFAULT_SERVER_URL
33
53
  namespace: str = "default"
54
+ env: Optional[str] = None
55
+
56
+ def __post_init__(self) -> None:
57
+ if self.env is not None:
58
+ preset = ENV_PRESETS.get(self.env)
59
+ if preset is None:
60
+ valid = ", ".join(sorted(ENV_PRESETS))
61
+ raise ValueError(
62
+ f"Unknown env preset {self.env!r}. Valid presets: {valid}"
63
+ )
64
+ # Explicit, non-default server_url takes precedence over the
65
+ # preset; only fill from the preset when server_url is untouched.
66
+ if self.server_url == DEFAULT_SERVER_URL:
67
+ self.server_url = preset
34
68
 
35
69
 
36
70
  # ============================================================