@totalreclaw/totalreclaw 1.0.5 → 1.2.0

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.
package/README.md CHANGED
@@ -1,122 +1,102 @@
1
- # TotalReclaw
1
+ <p align="center">
2
+ <img src="../../docs/assets/logo.png" alt="TotalReclaw" width="80" />
3
+ </p>
2
4
 
3
- Zero-knowledge encrypted memory vault for AI agents. Your agent remembers -- only you can read it.
5
+ <h1 align="center">@totalreclaw/totalreclaw</h1>
4
6
 
5
- ## What it does
7
+ <p align="center">
8
+ <strong>End-to-end encrypted memory for OpenClaw -- fully automatic, yours forever</strong>
9
+ </p>
6
10
 
7
- TotalReclaw gives your AI agent persistent, encrypted memory that works across sessions and devices. Memories are encrypted on your device before they reach any server. Not even TotalReclaw can read your data.
11
+ <p align="center">
12
+ <a href="https://totalreclaw.xyz">Website</a> &middot;
13
+ <a href="https://www.npmjs.com/package/@totalreclaw/totalreclaw">npm</a> &middot;
14
+ <a href="../../docs/guides/beta-tester-guide.md">Getting Started</a>
15
+ </p>
8
16
 
9
- - **True zero-knowledge E2EE** -- Client-side AES-256-GCM encryption. The server only sees encrypted blobs.
10
- - **Portable** -- One-click plaintext export. No vendor lock-in.
11
- - **Automatic** -- Memory extraction and recall happen via lifecycle hooks. No manual commands needed.
12
- - **Cross-device** -- Same 12-word recovery phrase restores all your memories on any device.
13
- - **98.1% recall** -- Blind-index search with BM25 + cosine + RRF fusion reranking.
14
- - **Free tier** -- 100 writes/month, unlimited reads. Upgrade at [totalreclaw.xyz/pricing](https://totalreclaw.xyz/pricing).
17
+ <p align="center">
18
+ <a href="https://www.npmjs.com/package/@totalreclaw/totalreclaw"><img src="https://img.shields.io/npm/v/@totalreclaw/totalreclaw?color=7B5CFF" alt="npm version"></a>
19
+ <a href="https://www.npmjs.com/package/@totalreclaw/totalreclaw"><img src="https://img.shields.io/npm/dm/@totalreclaw/totalreclaw" alt="npm downloads"></a>
20
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License"></a>
21
+ </p>
22
+
23
+ ---
24
+
25
+ Your AI agent remembers everything -- preferences, decisions, facts -- encrypted so only you can read it. Built for [OpenClaw](https://openclaw.ai) with fully automatic memory extraction and recall.
15
26
 
16
27
  ## Install
17
28
 
18
- ### OpenClaw (recommended)
29
+ Ask your OpenClaw agent:
19
30
 
20
- ```
31
+ > "Install the @totalreclaw/totalreclaw plugin"
32
+
33
+ Or from the terminal:
34
+
35
+ ```bash
21
36
  openclaw plugins install @totalreclaw/totalreclaw
22
37
  ```
23
38
 
24
- Or just ask your OpenClaw agent:
25
- > "Install the totalreclaw plugin"
26
-
27
- The plugin sets itself up on first run -- it will ask if you have an existing recovery phrase or need a new one.
39
+ The agent handles setup: generates your encryption keys, asks you to save a 12-word recovery phrase, and registers you. After that, memory is fully automatic.
28
40
 
29
- ### Other MCP-compatible agents
41
+ ## How It Works
30
42
 
31
- TotalReclaw also ships a standalone MCP server for Claude Desktop, Cursor, and others. See [totalreclaw.xyz](https://totalreclaw.xyz) for setup instructions.
43
+ After setup, everything happens in the background:
32
44
 
33
- ## How it works
45
+ - **Start of conversation** -- loads relevant memories from your encrypted vault
46
+ - **During conversation** -- extracts facts, preferences, and decisions automatically
47
+ - **Before context compaction** -- saves important context before the window is trimmed
34
48
 
35
- 1. **Install** -- Add the plugin and set a recovery phrase (12-word BIP-39 mnemonic).
36
- 2. **Talk normally** -- TotalReclaw automatically extracts facts, preferences, and decisions from your conversations, encrypts them client-side, and stores them on-chain.
37
- 3. **Recall** -- At the start of each new conversation, relevant memories are decrypted and injected into context. You can also search explicitly with the `totalreclaw_recall` tool.
38
- 4. **Export anytime** -- `totalreclaw_export` dumps all your memories as plaintext JSON or Markdown. Your data, your format.
49
+ All encryption happens client-side using AES-256-GCM. The server never sees your plaintext data.
39
50
 
40
51
  ## Tools
41
52
 
42
- | Tool | What it does |
53
+ Your agent gets these tools automatically:
54
+
55
+ | Tool | Description |
43
56
  |------|-------------|
44
- | `totalreclaw_remember` | Store a fact, preference, or decision |
45
- | `totalreclaw_recall` | Search and retrieve relevant memories |
46
- | `totalreclaw_forget` | Delete a specific memory (on-chain tombstone) |
57
+ | `totalreclaw_remember` | Manually store a fact |
58
+ | `totalreclaw_recall` | Search memories by natural language |
59
+ | `totalreclaw_forget` | Delete a specific memory |
47
60
  | `totalreclaw_export` | Export all memories as plaintext |
48
- | `totalreclaw_status` | Check subscription tier and quota |
49
- | `totalreclaw_generate_recovery_phrase` | Generate a secure 12-word BIP-39 mnemonic (onboarding) |
50
-
51
- ## Lifecycle hooks
52
-
53
- | Hook | When | What |
54
- |------|------|------|
55
- | `before_agent_start` | Every conversation | Recalls relevant memories and injects them into context |
56
- | `agent_end` | After each turn | Extracts new facts from the conversation |
57
- | `pre_compaction` | Before context compaction | Full memory extraction to prevent data loss |
58
-
59
- ## Recovery phrase
60
-
61
- Your recovery phrase is a 12-word BIP-39 mnemonic -- like a crypto wallet seed. It derives all encryption keys locally. The server never sees it.
62
-
63
- - **New user**: The plugin generates a random phrase and displays it once. Save it somewhere safe.
64
- - **Returning user**: Enter your existing phrase to restore all your memories on a new device.
65
- - **Lost phrase**: Memories cannot be recovered. This is the zero-knowledge guarantee.
66
-
67
- ## Privacy and security
61
+ | `totalreclaw_status` | Check billing status and quota |
62
+ | `totalreclaw_consolidate` | Merge duplicate memories |
63
+ | `totalreclaw_import_from` | Import from Mem0 or MCP Memory Server |
68
64
 
69
- - All encryption happens client-side (AES-256-GCM + HKDF key derivation)
70
- - The server stores only encrypted blobs and blind indices
71
- - On-chain storage via Gnosis Chain (The Graph subgraph) -- fully auditable
72
- - Master password never leaves your device
73
- - One-click plaintext export -- no vendor lock-in
65
+ Most of the time you won't use these directly -- the automatic hooks handle memory for you.
74
66
 
75
- ## Configuration
67
+ ## Features
76
68
 
77
- | Environment variable | Default | Description |
78
- |---------------------|---------|-------------|
79
- | `TOTALRECLAW_MASTER_PASSWORD` | *(required)* | 12-word BIP-39 recovery phrase |
80
- | `TOTALRECLAW_SERVER_URL` | `https://api.totalreclaw.xyz` | Relay server URL |
81
- | `TOTALRECLAW_SUBGRAPH_MODE` | `true` | Enable on-chain storage |
82
- | `TOTALRECLAW_EXTRACT_EVERY_TURNS` | `5` | Turns between automatic extractions |
69
+ - **End-to-end encrypted** -- AES-256-GCM encryption, blind index search, HKDF auth
70
+ - **Automatic extraction** -- LLM extracts facts from conversations, no manual input needed
71
+ - **Semantic search** -- Local embeddings + BM25 + cosine reranking with RRF fusion
72
+ - **Smart dedup** -- Cosine similarity catches paraphrases; LLM-guided dedup catches contradictions (Pro)
73
+ - **On-chain storage** -- Encrypted data stored on Gnosis Chain, indexed by The Graph
74
+ - **Portable** -- One 12-word phrase. Any device, same memories, no lock-in
75
+ - **Import** -- Migrate from Mem0 or MCP Memory Server
83
76
 
84
- ## How TotalReclaw compares
77
+ ## Free Tier & Pricing
85
78
 
86
- Every AI memory tool stores your data on a server that can read it. TotalReclaw is the only one that encrypts on your device first -- a compromised server reveals nothing.
79
+ | Tier | Memories | Reads | Storage | Price |
80
+ |------|----------|-------|---------|-------|
81
+ | **Free** | 500/month | Unlimited | Testnet (trial) | $0 |
82
+ | **Pro** | Unlimited | Unlimited | Permanent on-chain (Gnosis) | $5/month |
87
83
 
88
- | | TotalReclaw | Mem0 | Zep | Letta | MCP Memory | memU |
89
- |---|---|---|---|---|---|---|
90
- | **Server sees plaintext** | Never | Yes | Yes | Yes | N/A (local) | Yes |
91
- | **Client-side E2EE** | AES-256-GCM | No | No | No | No | No |
92
- | **Cross-device sync** | Seed phrase | Account | Account | Account | No | Account |
93
- | **Data export** | One-click plaintext | JSON (7-day link) | No | Via API | Copy file | No |
94
- | **On-chain storage** | Gnosis Chain | No | No | No | No | No |
95
- | **Self-hostable** | Yes | Yes | No | Yes | Yes (local) | Yes |
96
- | **OpenClaw plugin** | Yes | Yes | No | No | No | Yes |
97
- | **MCP server** | Yes | Yes | No | No | Yes | No |
98
- | **Knowledge graph** | No | Yes ($249/mo) | Yes | Yes | Simple | No |
99
- | **Free tier** | 100 writes/mo | 10K memories | 1K credits | 3 agents | Unlimited | Varies |
84
+ Pay with card via Stripe. Counter resets monthly.
100
85
 
101
- ### Where TotalReclaw wins
86
+ ## Using with Other Agents
102
87
 
103
- - **Zero-knowledge encryption** -- No other memory tool encrypts client-side. Mem0 and Zep offer SOC 2 and HIPAA, but their servers still process your plaintext. TotalReclaw's server only ever sees encrypted blobs.
104
- - **Seed-phrase portability** -- One 12-word phrase, any device, any agent. No accounts, no passwords, no vendor. Works like a crypto wallet.
105
- - **On-chain anchoring** -- Memories are stored on Gnosis Chain and indexed by The Graph. No single server controls your data.
106
- - **True data ownership** -- One-click plaintext export. No 7-day expiry links, no API-only access. Your data, your format.
88
+ TotalReclaw also works outside OpenClaw:
107
89
 
108
- ### Where others win
90
+ - **Claude Desktop / Cursor / Windsurf** -- Use [@totalreclaw/mcp-server](https://www.npmjs.com/package/@totalreclaw/mcp-server)
91
+ - **NanoClaw** -- Built-in support via MCP bridge
109
92
 
110
- - **Knowledge graphs** -- Zep's temporal graph tracks how facts evolve over time. Mem0's graph memory ($249/mo) maps entity relationships. TotalReclaw can't build graphs because the server can't read the data -- that's the privacy trade-off.
111
- - **Ecosystem maturity** -- Mem0 has 49K GitHub stars, $24M in funding, and integrations with every major framework. TotalReclaw is a beta product.
112
- - **Offline simplicity** -- The official MCP Memory Server and Engram need zero network, zero accounts, zero setup. Good enough for single-device use.
113
- - **Enterprise compliance** -- Mem0 and Zep offer SOC 2, HIPAA, RBAC, SSO. TotalReclaw doesn't need most of these (zero-knowledge means there's nothing to comply about), but enterprises want the paperwork.
93
+ Same encryption, same recovery phrase, same memories across all agents.
114
94
 
115
- ## Links
95
+ ## Learn More
116
96
 
117
- - [Website](https://totalreclaw.xyz)
118
- - [Documentation](https://github.com/p-diogo/totalreclaw)
119
- - [Pricing](https://totalreclaw.xyz/pricing)
97
+ - [Getting Started Guide](../../docs/guides/beta-tester-guide.md)
98
+ - [totalreclaw.xyz](https://totalreclaw.xyz)
99
+ - [Main Repository](https://github.com/p-diogo/totalreclaw)
120
100
 
121
101
  ## License
122
102
 
package/api-client.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * TotalReclaw Plugin - HTTP API Client
3
+ *
4
+ * Communicates with the TotalReclaw server over JSON/HTTP. Uses Node.js
5
+ * built-in `fetch` (available since Node 18).
6
+ *
7
+ * All authenticated endpoints expect:
8
+ * Authorization: Bearer <hex-encoded-auth-key>
9
+ *
10
+ * The server hashes the auth key with SHA-256 to look up the user.
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Request / Response Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * A single fact payload for the `/v1/store` endpoint.
19
+ *
20
+ * Field naming matches the server's `FactJSON` Pydantic model in
21
+ * `server/src/handlers/store.py`.
22
+ */
23
+ export interface StoreFactPayload {
24
+ /** UUIDv7 fact identifier */
25
+ id: string;
26
+ /** ISO 8601 timestamp */
27
+ timestamp: string;
28
+ /** Hex-encoded AES-256-GCM ciphertext (iv || tag || ciphertext) */
29
+ encrypted_blob: string;
30
+ /** SHA-256 hashes of tokens for blind search */
31
+ blind_indices: string[];
32
+ /** Importance / decay score (0-10) */
33
+ decay_score: number;
34
+ /** Origin label */
35
+ source: string;
36
+ /** HMAC-SHA256 content fingerprint for dedup (hex) */
37
+ content_fp?: string;
38
+ /** Identifier of the creating agent */
39
+ agent_id?: string;
40
+ /** Hex-encoded AES-256-GCM encrypted embedding vector (PoC v2) */
41
+ encrypted_embedding?: string;
42
+ }
43
+
44
+ /**
45
+ * A search result candidate returned by `/v1/search`.
46
+ *
47
+ * Field naming matches the server's `SearchResultJSON` model.
48
+ */
49
+ export interface SearchCandidate {
50
+ fact_id: string;
51
+ /** Hex-encoded AES-256-GCM ciphertext */
52
+ encrypted_blob: string;
53
+ decay_score: number;
54
+ /** Unix milliseconds */
55
+ timestamp: number;
56
+ version: number;
57
+ /** Hex-encoded AES-256-GCM encrypted embedding vector (PoC v2, optional) */
58
+ encrypted_embedding?: string;
59
+ }
60
+
61
+ /**
62
+ * A fact object returned by `/v1/export`.
63
+ */
64
+ export interface ExportedFact {
65
+ id: string;
66
+ encrypted_blob: string;
67
+ blind_indices: string[];
68
+ decay_score: number;
69
+ version: number;
70
+ source: string;
71
+ created_at: string;
72
+ updated_at: string;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // API Client Factory
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Create an API client bound to a specific TotalReclaw server URL.
81
+ *
82
+ * All methods are async and throw descriptive errors on non-2xx responses.
83
+ */
84
+ export function createApiClient(serverUrl: string) {
85
+ // Normalise URL -- strip trailing slash.
86
+ const baseUrl = serverUrl.replace(/\/+$/, '');
87
+
88
+ // ------------------------------------------------------------------
89
+ // Shared helpers
90
+ // ------------------------------------------------------------------
91
+
92
+ /**
93
+ * Throw a descriptive error when the server returns a non-2xx status.
94
+ */
95
+ async function assertOk(res: Response, context: string): Promise<void> {
96
+ if (res.ok) return;
97
+ let body: string;
98
+ try {
99
+ body = await res.text();
100
+ } catch {
101
+ body = '(could not read response body)';
102
+ }
103
+ const hint = res.status === 401
104
+ ? ' Authentication failed. If using a recovery phrase, check that all 12 words are in the correct order and spelled correctly.'
105
+ : '';
106
+ throw new Error(`${context}: HTTP ${res.status} - ${body}${hint}`);
107
+ }
108
+
109
+ // ------------------------------------------------------------------
110
+ // Public methods
111
+ // ------------------------------------------------------------------
112
+
113
+ return {
114
+ // ---- Registration (unauthenticated) ----
115
+
116
+ /**
117
+ * Register a new user.
118
+ *
119
+ * @param authKeyHash Hex-encoded SHA-256 of the auth key (64 chars).
120
+ * @param saltHex Hex-encoded 32-byte salt (64 chars).
121
+ * @returns `{ user_id }` on success.
122
+ */
123
+ async register(
124
+ authKeyHash: string,
125
+ saltHex: string,
126
+ ): Promise<{ user_id: string }> {
127
+ const res = await fetch(`${baseUrl}/v1/register`, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
131
+ });
132
+ await assertOk(res, 'register');
133
+ const json = (await res.json()) as Record<string, unknown>;
134
+ if (!json.success && json.error_code !== 'USER_EXISTS') {
135
+ throw new Error(
136
+ `register: server returned success=false - ${json.error_code}: ${json.error_message}`,
137
+ );
138
+ }
139
+ if (!json.user_id) {
140
+ throw new Error(
141
+ `register: server did not return user_id (error_code=${json.error_code})`,
142
+ );
143
+ }
144
+ return { user_id: json.user_id as string };
145
+ },
146
+
147
+ // ---- Store (authenticated) ----
148
+
149
+ /**
150
+ * Store one or more encrypted facts.
151
+ *
152
+ * @param userId The authenticated user's ID.
153
+ * @param facts Array of `StoreFactPayload` objects.
154
+ * @param authKeyHex Hex-encoded raw auth key (64 chars) for Bearer header.
155
+ */
156
+ async store(
157
+ userId: string,
158
+ facts: StoreFactPayload[],
159
+ authKeyHex: string,
160
+ ): Promise<{ ids: string[]; duplicate_ids?: string[] }> {
161
+ const res = await fetch(`${baseUrl}/v1/store`, {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ Authorization: `Bearer ${authKeyHex}`,
166
+ },
167
+ body: JSON.stringify({ user_id: userId, facts }),
168
+ });
169
+ await assertOk(res, 'store');
170
+ const json = (await res.json()) as Record<string, unknown>;
171
+ if (!json.success) {
172
+ throw new Error(
173
+ `store: server returned success=false - ${json.error_code}: ${json.error_message}`,
174
+ );
175
+ }
176
+ return {
177
+ ids: (json.ids as string[]) ?? [],
178
+ duplicate_ids: json.duplicate_ids as string[] | undefined,
179
+ };
180
+ },
181
+
182
+ // ---- Search (authenticated) ----
183
+
184
+ /**
185
+ * Search for facts using blind trapdoors.
186
+ *
187
+ * @param userId The authenticated user's ID.
188
+ * @param trapdoors SHA-256 hex hashes of query tokens.
189
+ * @param maxCandidates Maximum candidates to retrieve.
190
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
191
+ * @returns Array of encrypted search candidates.
192
+ */
193
+ async search(
194
+ userId: string,
195
+ trapdoors: string[],
196
+ maxCandidates: number,
197
+ authKeyHex: string,
198
+ ): Promise<SearchCandidate[]> {
199
+ const res = await fetch(`${baseUrl}/v1/search`, {
200
+ method: 'POST',
201
+ headers: {
202
+ 'Content-Type': 'application/json',
203
+ Authorization: `Bearer ${authKeyHex}`,
204
+ },
205
+ body: JSON.stringify({
206
+ user_id: userId,
207
+ trapdoors,
208
+ max_candidates: maxCandidates,
209
+ }),
210
+ });
211
+ await assertOk(res, 'search');
212
+ const json = (await res.json()) as Record<string, unknown>;
213
+ if (!json.success) {
214
+ throw new Error(
215
+ `search: server returned success=false - ${json.error_code}: ${json.error_message}`,
216
+ );
217
+ }
218
+ return (json.results as SearchCandidate[]) ?? [];
219
+ },
220
+
221
+ // ---- Delete (authenticated) ----
222
+
223
+ /**
224
+ * Soft-delete a fact by ID.
225
+ *
226
+ * @param factId The fact UUID to delete.
227
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
228
+ */
229
+ async deleteFact(factId: string, authKeyHex: string): Promise<void> {
230
+ const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
231
+ method: 'DELETE',
232
+ headers: {
233
+ Authorization: `Bearer ${authKeyHex}`,
234
+ },
235
+ });
236
+ await assertOk(res, 'deleteFact');
237
+ const json = (await res.json()) as Record<string, unknown>;
238
+ if (!json.success) {
239
+ throw new Error(
240
+ `deleteFact: server returned success=false - ${json.error_code}: ${json.error_message}`,
241
+ );
242
+ }
243
+ },
244
+
245
+ // ---- Batch Delete (authenticated) ----
246
+
247
+ /**
248
+ * Batch soft-delete facts by ID list.
249
+ *
250
+ * @param factIds Array of fact UUIDs to delete (max 500).
251
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
252
+ * @returns The number of facts that were actually deleted.
253
+ */
254
+ async batchDelete(factIds: string[], authKeyHex: string): Promise<number> {
255
+ const res = await fetch(`${baseUrl}/v1/facts/batch-delete`, {
256
+ method: 'POST',
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ Authorization: `Bearer ${authKeyHex}`,
260
+ },
261
+ body: JSON.stringify({ fact_ids: factIds }),
262
+ });
263
+ await assertOk(res, 'batchDelete');
264
+ const json = (await res.json()) as Record<string, unknown>;
265
+ if (!json.success) {
266
+ throw new Error(
267
+ `batchDelete: server returned success=false - ${json.error_code}: ${json.error_message}`,
268
+ );
269
+ }
270
+ return (json.deleted_count as number) ?? 0;
271
+ },
272
+
273
+ // ---- Export (authenticated) ----
274
+
275
+ /**
276
+ * Export all active facts (paginated).
277
+ *
278
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
279
+ * @param limit Page size (default 1000, max 5000).
280
+ * @param cursor Cursor from previous page (omit for first page).
281
+ * @returns Page of facts with pagination metadata.
282
+ */
283
+ async exportFacts(
284
+ authKeyHex: string,
285
+ limit: number = 1000,
286
+ cursor?: string,
287
+ ): Promise<{ facts: ExportedFact[]; cursor?: string; has_more: boolean; total_count?: number }> {
288
+ const params = new URLSearchParams({ limit: String(limit) });
289
+ if (cursor) params.set('cursor', cursor);
290
+
291
+ const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
292
+ method: 'GET',
293
+ headers: {
294
+ Authorization: `Bearer ${authKeyHex}`,
295
+ },
296
+ });
297
+ await assertOk(res, 'exportFacts');
298
+ const json = (await res.json()) as Record<string, unknown>;
299
+ if (!json.success) {
300
+ throw new Error(
301
+ `exportFacts: server returned success=false - ${json.error_code}: ${json.error_message}`,
302
+ );
303
+ }
304
+ return {
305
+ facts: (json.facts as ExportedFact[]) ?? [],
306
+ cursor: json.cursor as string | undefined,
307
+ has_more: (json.has_more as boolean) ?? false,
308
+ total_count: json.total_count as number | undefined,
309
+ };
310
+ },
311
+
312
+ // ---- Health (unauthenticated) ----
313
+
314
+ /**
315
+ * Check server health.
316
+ *
317
+ * @returns `true` if the server responds with HTTP 200.
318
+ */
319
+ async health(): Promise<boolean> {
320
+ try {
321
+ const res = await fetch(`${baseUrl}/health`, { method: 'GET' });
322
+ return res.status === 200;
323
+ } catch {
324
+ return false;
325
+ }
326
+ },
327
+ };
328
+ }