@totalreclaw/totalreclaw 3.3.1-rc.8 → 3.3.1

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +268 -1
  2. package/SKILL.md +29 -23
  3. package/api-client.ts +18 -11
  4. package/claims-helper.ts +47 -1
  5. package/config.ts +108 -4
  6. package/confirm-indexed.ts +191 -0
  7. package/crypto.ts +10 -2
  8. package/dist/api-client.js +226 -0
  9. package/dist/billing-cache.js +100 -0
  10. package/dist/claims-helper.js +624 -0
  11. package/dist/config.js +297 -0
  12. package/dist/confirm-indexed.js +127 -0
  13. package/dist/consolidation.js +258 -0
  14. package/dist/contradiction-sync.js +1034 -0
  15. package/dist/crypto.js +138 -0
  16. package/dist/digest-sync.js +361 -0
  17. package/dist/download-ux.js +63 -0
  18. package/dist/embedder-cache.js +185 -0
  19. package/dist/embedder-loader.js +121 -0
  20. package/dist/embedder-network.js +301 -0
  21. package/dist/embedding.js +141 -0
  22. package/dist/extractor.js +1225 -0
  23. package/dist/first-run.js +103 -0
  24. package/dist/fs-helpers.js +725 -0
  25. package/dist/gateway-url.js +197 -0
  26. package/dist/generate-mnemonic.js +13 -0
  27. package/dist/hot-cache-wrapper.js +101 -0
  28. package/dist/import-adapters/base-adapter.js +64 -0
  29. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  30. package/dist/import-adapters/claude-adapter.js +114 -0
  31. package/dist/import-adapters/gemini-adapter.js +201 -0
  32. package/dist/import-adapters/index.js +26 -0
  33. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  34. package/dist/import-adapters/mem0-adapter.js +158 -0
  35. package/dist/import-adapters/types.js +1 -0
  36. package/dist/index.js +5388 -0
  37. package/dist/llm-client.js +687 -0
  38. package/dist/llm-profile-reader.js +346 -0
  39. package/dist/lsh.js +62 -0
  40. package/dist/onboarding-cli.js +750 -0
  41. package/dist/pair-cli.js +344 -0
  42. package/dist/pair-crypto.js +359 -0
  43. package/dist/pair-http.js +404 -0
  44. package/dist/pair-page.js +826 -0
  45. package/dist/pair-qr.js +107 -0
  46. package/dist/pair-remote-client.js +410 -0
  47. package/dist/pair-session-store.js +566 -0
  48. package/dist/pin.js +556 -0
  49. package/dist/qa-bug-report.js +301 -0
  50. package/dist/relay-headers.js +44 -0
  51. package/dist/reranker.js +409 -0
  52. package/dist/retype-setscope.js +368 -0
  53. package/dist/semantic-dedup.js +75 -0
  54. package/dist/subgraph-search.js +289 -0
  55. package/dist/subgraph-store.js +694 -0
  56. package/dist/tool-gating.js +58 -0
  57. package/download-ux.ts +91 -0
  58. package/embedder-cache.ts +230 -0
  59. package/embedder-loader.ts +189 -0
  60. package/embedder-network.ts +350 -0
  61. package/embedding.ts +118 -27
  62. package/fs-helpers.ts +277 -0
  63. package/gateway-url.ts +57 -9
  64. package/index.ts +469 -250
  65. package/llm-client.ts +4 -3
  66. package/lsh.ts +7 -2
  67. package/onboarding-cli.ts +114 -1
  68. package/package.json +24 -5
  69. package/pair-cli.ts +76 -8
  70. package/pair-crypto.ts +34 -24
  71. package/pair-page.ts +28 -17
  72. package/pair-qr.ts +152 -0
  73. package/pair-remote-client.ts +540 -0
  74. package/pin.ts +31 -0
  75. package/qa-bug-report.ts +84 -2
  76. package/relay-headers.ts +50 -0
  77. package/reranker.ts +40 -0
  78. package/retype-setscope.ts +69 -8
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +4 -3
  81. package/subgraph-store.ts +15 -10
package/config.ts CHANGED
@@ -9,8 +9,15 @@
9
9
  *
10
10
  * v1 env var cleanup — see `docs/guides/env-vars-reference.md`.
11
11
  * Removed user-facing vars: TOTALRECLAW_CHAIN_ID, TOTALRECLAW_EMBEDDING_MODEL,
12
- * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_SESSION_ID,
13
- * TOTALRECLAW_TAXONOMY_VERSION.
12
+ * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_TAXONOMY_VERSION.
13
+ *
14
+ * NOTE: ``TOTALRECLAW_SESSION_ID`` was in the removed list during the v1
15
+ * cleanup and silently rejected with a warning. That broke Axiom log tracing
16
+ * for QA — the qa-totalreclaw skill prescribes setting the var so relay logs
17
+ * are searchable by ``X-TotalReclaw-Session``. Restored as a SUPPORTED
18
+ * variable: read here, forwarded as the ``X-TotalReclaw-Session`` header on
19
+ * every outbound relay call. Mirrors the Python-side fix
20
+ * (`python/src/totalreclaw/agent/state.py`, v2.0.2). See internal#127.
14
21
  * Removed legacy gates: TOTALRECLAW_CLAIM_FORMAT, TOTALRECLAW_DIGEST_MODE,
15
22
  * TOTALRECLAW_AUTO_RESOLVE_MODE (the last one moved to an internal debug
16
23
  * module; see `contradiction-sync.ts`).
@@ -33,18 +40,27 @@ const REMOVED_ENV_VARS = [
33
40
  'TOTALRECLAW_EMBEDDING_MODEL',
34
41
  'TOTALRECLAW_STORE_DEDUP',
35
42
  'TOTALRECLAW_LLM_MODEL',
36
- 'TOTALRECLAW_SESSION_ID',
43
+ // NOTE: TOTALRECLAW_SESSION_ID was here before; restored as SUPPORTED
44
+ // (forwarded as X-TotalReclaw-Session header). Do NOT add it back to this
45
+ // list — see file header + internal#127.
37
46
  'TOTALRECLAW_TAXONOMY_VERSION',
38
47
  'TOTALRECLAW_CLAIM_FORMAT',
39
48
  'TOTALRECLAW_DIGEST_MODE',
40
49
  ] as const;
41
50
 
51
+ // Migration guide URL — kept as a constant so the regression test can assert
52
+ // the exact link text in the warning. Pointing at GitHub raw-blob is more
53
+ // useful than the relative repo path: operators copying the warning out of
54
+ // stderr usually do not have the repo cloned. rc.22 finding #4.
55
+ export const ENV_VARS_REFERENCE_URL =
56
+ 'https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/env-vars-reference.md';
57
+
42
58
  function warnRemovedEnvVars(warn: (msg: string) => void = console.warn): void {
43
59
  const set = REMOVED_ENV_VARS.filter((name) => process.env[name] !== undefined);
44
60
  if (set.length === 0) return;
45
61
  warn(
46
62
  `TotalReclaw: ignoring removed env var(s): ${set.join(', ')}. ` +
47
- `See docs/guides/env-vars-reference.md for the v1 env var surface.`,
63
+ `Migration guide: ${ENV_VARS_REFERENCE_URL}`,
48
64
  );
49
65
  }
50
66
 
@@ -63,6 +79,27 @@ export function getRecoveryPhrase(): string {
63
79
  return _recoveryPhraseOverride ?? process.env.TOTALRECLAW_RECOVERY_PHRASE ?? '';
64
80
  }
65
81
 
82
+ /**
83
+ * Read the QA / observability session tag from the environment.
84
+ *
85
+ * When set, every outbound relay call adds the ``X-TotalReclaw-Session``
86
+ * header so relay logs (and Axiom queries) can be filtered by this tag —
87
+ * this is what the qa-totalreclaw skill relies on to scope log searches per
88
+ * QA run. When unset, returns ``null`` and the header is omitted.
89
+ *
90
+ * Read via getter (not snapshotted) so operators / test harnesses can flip
91
+ * the var between calls without reloading the module.
92
+ *
93
+ * Mirrors the Python-side ``RelayClient._session_id`` resolution priority.
94
+ * See internal#127 / `docs/guides/env-vars-reference.md`.
95
+ */
96
+ export function getSessionId(): string | null {
97
+ const raw = process.env.TOTALRECLAW_SESSION_ID;
98
+ if (raw === undefined) return null;
99
+ const trimmed = raw.trim();
100
+ return trimmed.length > 0 ? trimmed : null;
101
+ }
102
+
66
103
  /**
67
104
  * Runtime override for chain ID, set after the relay billing response is
68
105
  * read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
@@ -92,6 +129,15 @@ export const CONFIG = {
92
129
  get recoveryPhrase(): string {
93
130
  return getRecoveryPhrase();
94
131
  },
132
+ /**
133
+ * Optional QA / observability session tag forwarded to the relay as
134
+ * ``X-TotalReclaw-Session``. See `getSessionId()` above. Getter form so
135
+ * tests + harnesses can flip the env between calls. ``null`` when unset
136
+ * (header omitted).
137
+ */
138
+ get sessionId(): string | null {
139
+ return getSessionId();
140
+ },
95
141
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
96
142
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
97
143
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
@@ -105,6 +151,21 @@ export const CONFIG = {
105
151
  // for 15-min TTL windows; 0600 mode.
106
152
  pairSessionsPath: process.env.TOTALRECLAW_PAIR_SESSIONS_PATH || path.join(home, '.totalreclaw', 'pair-sessions.json'),
107
153
 
154
+ // 3.3.1-rc.11 — pair-flow transport selector. Mirrors the Python-side
155
+ // `TOTALRECLAW_PAIR_MODE` env (rc.10). `'relay'` (default) routes
156
+ // `totalreclaw_pair` through the universal-reachability WebSocket relay at
157
+ // `TOTALRECLAW_PAIR_RELAY_URL`. `'local'` preserves the rc.4–rc.10 loopback
158
+ // HTTP flow (the plugin serves `/plugin/totalreclaw/pair/*` via
159
+ // `pair-http.ts`). Air-gapped / self-hosted users can pin `'local'` here.
160
+ pairMode: (() => {
161
+ const v = (process.env.TOTALRECLAW_PAIR_MODE ?? '').trim().toLowerCase();
162
+ return v === 'local' ? 'local' : 'relay';
163
+ })() as 'relay' | 'local',
164
+ // 3.3.1-rc.11 — relay base URL for the WebSocket-brokered pair flow.
165
+ // `wss://` preferred; `https://` is rewritten in the remote-client.
166
+ pairRelayUrl: (process.env.TOTALRECLAW_PAIR_RELAY_URL
167
+ || 'wss://api-staging.totalreclaw.xyz').replace(/\/+$/, ''),
168
+
108
169
  // Chain — chainId is no longer user-configurable. It is auto-detected from
109
170
  // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
110
171
  // 100). The default here is used only before the first billing lookup
@@ -188,11 +249,54 @@ export const CONFIG = {
188
249
  return process.env.TOTALRECLAW_QA_GITHUB_TOKEN || process.env.GITHUB_TOKEN || '';
189
250
  },
190
251
 
252
+ // 3.3.1-rc.14: optional target-repo override for the RC-gated QA
253
+ // bug-report tool. The `qa-bug-report` module enforces a
254
+ // "slug ends in `-internal`" rule on whatever is resolved here, so
255
+ // this override is only useful for forks / mirrors of the internal
256
+ // tracker. Leaving unset uses the production default
257
+ // (`p-diogo/totalreclaw-internal`). Read via getter so operators can
258
+ // flip the var at runtime.
259
+ get qaRepoOverride(): string {
260
+ return process.env.TOTALRECLAW_QA_REPO || '';
261
+ },
262
+
263
+ // 3.3.1-rc.21 (issue #128): verbose-register flag. When enabled, the
264
+ // plugin emits opt-in `info`-level breadcrumbs after sensitive
265
+ // registerTool calls (currently `totalreclaw_pair`) to help ops/QA
266
+ // grep gateway logs for definitive proof the tool was declared.
267
+ // Default OFF — the breadcrumb is debug-grade and was bleeding into
268
+ // `openclaw agent --json` stdout, breaking programmatic parsers.
269
+ // Enable with either:
270
+ // TOTALRECLAW_VERBOSE_REGISTER=1 (specific opt-in)
271
+ // TOTALRECLAW_DEBUG=1 (general debug toggle)
272
+ // Read via getter so flipping the env at runtime takes effect on the
273
+ // next gateway start without a rebuild.
274
+ get verboseRegister(): boolean {
275
+ const specific = (process.env.TOTALRECLAW_VERBOSE_REGISTER ?? '').trim().toLowerCase();
276
+ if (specific === '1' || specific === 'true' || specific === 'yes') return true;
277
+ const general = (process.env.TOTALRECLAW_DEBUG ?? '').trim().toLowerCase();
278
+ return general === '1' || general === 'true' || general === 'yes';
279
+ },
280
+
191
281
  // Paths
192
282
  home,
193
283
  billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
194
284
  cachePath: process.env.TOTALRECLAW_CACHE_PATH || path.join(home, '.totalreclaw', 'cache.enc'),
195
285
  openclawWorkspace: path.join(home, '.openclaw', 'workspace'),
286
+
287
+ // 3.3.1-rc.22 — lazy embedder bundle cache. The embedder
288
+ // (`@huggingface/transformers` + `onnxruntime-node` + the q4 ONNX
289
+ // model) is no longer shipped inside the plugin tarball; it is fetched
290
+ // on first `embed()` call from a versioned GitHub Release and cached
291
+ // here. Separate path from `cachePath` (encrypted vault cache) so the
292
+ // two never collide. See `embedder-loader.ts`.
293
+ embedderCachePath: process.env.TOTALRECLAW_EMBEDDER_CACHE_PATH || path.join(home, '.totalreclaw', 'embedder'),
294
+
295
+ // 3.3.1-rc.22 — override the GitHub-Releases URL templates. Only useful
296
+ // for air-gapped / mirror deployments and self-hosted CI. Empty string
297
+ // falls back to the static defaults baked into the embedder code path.
298
+ embedderBundleUrlTemplate: process.env.TOTALRECLAW_EMBEDDER_BUNDLE_URL || '',
299
+ embedderManifestUrlTemplate: process.env.TOTALRECLAW_EMBEDDER_MANIFEST_URL || '',
196
300
  } as const;
197
301
 
198
302
  // ---------------------------------------------------------------------------
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Read-after-write primitive — confirm a fact id has been indexed by the
3
+ * subgraph after an on-chain mutation (retype / set_scope / pin / unpin /
4
+ * forget).
5
+ *
6
+ * Wraps the pure-compute halves exported by `@totalreclaw/core`
7
+ * (`wasmConfirmIndexedQuery`, `wasmConfirmIndexedParse`) in a host-side
8
+ * polling loop. Subgraph indexer lag on Gnosis production runs 5-30s; this
9
+ * helper polls every `pollIntervalMs` (default 1000ms) up to `timeoutMs`
10
+ * (default 30000ms).
11
+ *
12
+ * Why this exists
13
+ * ---------------
14
+ * Pre-fix, mutation tools returned `{success: true}` based on the bundler
15
+ * ack alone. A user who immediately ran `totalreclaw_export` would see the
16
+ * pre-mutation state, because the subgraph indexer hadn't yet observed the
17
+ * L1 inclusion. Confusing UX, root cause of rc.18 finding #117.
18
+ *
19
+ * Post-fix, mutation tools call `confirmIndexed(newFactId)` after submitting
20
+ * the batched UserOp; on success they return normally, on timeout they
21
+ * return `{success: true, partial: true, ...}` with the chain write
22
+ * acknowledged but the indexer-level confirmation withheld.
23
+ *
24
+ * Mnemonic isolation: this helper never touches the mnemonic, encryption
25
+ * key, or any decrypted blob. Only reads the public {id, isActive,
26
+ * blockNumber} of a fact.
27
+ */
28
+
29
+ import { createRequire } from 'node:module';
30
+ import { getSubgraphConfig } from './subgraph-store.js';
31
+ import { buildRelayHeaders } from './relay-headers.js';
32
+
33
+ /**
34
+ * The four `wasmConfirmIndexed*` exports below ship in `@totalreclaw/core@2.3.x`
35
+ * (the `confirm` module added in 2.2.x). Older type declarations don't list
36
+ * them — we use a structural cast so the plugin's `--noCheck` build is
37
+ * unblocked and runtime resolution is via dynamic require.
38
+ */
39
+ type ConfirmIndexedCore = typeof import('@totalreclaw/core') & {
40
+ wasmConfirmIndexedQuery(): string;
41
+ wasmConfirmIndexedParse(responseJson: string): boolean;
42
+ wasmConfirmIndexedDefaultPollMs(): number;
43
+ wasmConfirmIndexedDefaultTimeoutMs(): number;
44
+ };
45
+
46
+ const requireWasm = createRequire(import.meta.url);
47
+ let _wasm: ConfirmIndexedCore | null = null;
48
+ function getWasm(): ConfirmIndexedCore {
49
+ if (!_wasm) _wasm = requireWasm('@totalreclaw/core') as unknown as ConfirmIndexedCore;
50
+ return _wasm!;
51
+ }
52
+
53
+ /** Result of a confirm-indexed poll loop. */
54
+ export interface ConfirmIndexedResult {
55
+ /** True if the fact was found and `isActive == true` before the timeout. */
56
+ indexed: boolean;
57
+ /** Number of poll attempts before resolution (or timeout). */
58
+ attempts: number;
59
+ /** Total wall-clock ms spent polling. */
60
+ elapsedMs: number;
61
+ /** Last error message from the GraphQL adapter, if any. */
62
+ lastError?: string;
63
+ }
64
+
65
+ export interface ConfirmIndexedOptions {
66
+ /** Override the default 1s poll interval. */
67
+ pollIntervalMs?: number;
68
+ /** Override the default 30s total timeout. */
69
+ timeoutMs?: number;
70
+ /** Override `${relayUrl}/v1/subgraph` (test injection point). */
71
+ subgraphUrl?: string;
72
+ /** Override the auth-key-hex header (defaults to none). */
73
+ authKeyHex?: string;
74
+ /**
75
+ * Direction of the read-after-write check.
76
+ * - `"active"` (default) — wait until fact is found AND `isActive == true`.
77
+ * Use after pin/unpin/retype/set_scope (we're confirming the *new* fact
78
+ * id has appeared).
79
+ * - `"inactive"` — wait until fact either disappears OR `isActive ==
80
+ * false`. Use after `forget`, where the *original* fact id is being
81
+ * tombstoned and a confirm-indexed wait should resolve once the
82
+ * subgraph has flipped it.
83
+ */
84
+ expect?: 'active' | 'inactive';
85
+ /**
86
+ * Test injection — caller-provided `fetch`-compatible POST. Defaults to
87
+ * the global `fetch`. Letting tests stub this out avoids a network mock
88
+ * dependency.
89
+ */
90
+ poster?: (url: string, body: string, headers: Record<string, string>) => Promise<{
91
+ ok: boolean;
92
+ status: number;
93
+ text: () => Promise<string>;
94
+ }>;
95
+ }
96
+
97
+ /**
98
+ * Poll the subgraph until the new fact id is indexed-and-active, or the
99
+ * timeout elapses. Returns a result object describing the outcome — never
100
+ * throws on indexer-level transient errors; the caller decides whether to
101
+ * surface a `partial: true` flag based on `result.indexed`.
102
+ *
103
+ * The host's submitBatch already returned a tx hash before this is called,
104
+ * so on `indexed: false` the on-chain write is still acknowledged — just not
105
+ * yet visible in the read API.
106
+ */
107
+ export async function confirmIndexed(
108
+ factId: string,
109
+ options: ConfirmIndexedOptions = {},
110
+ ): Promise<ConfirmIndexedResult> {
111
+ // WASM bindings may be unavailable (e.g. core@<2.3.0 not yet published).
112
+ // In that case the chain write has still succeeded — confirm step is
113
+ // observational only. Return `indexed: false` so callers surface
114
+ // `partial: true` rather than fail the whole tool invocation.
115
+ let wasm: ConfirmIndexedCore;
116
+ let query: string;
117
+ let pollIntervalMs: number;
118
+ let timeoutMs: number;
119
+ try {
120
+ wasm = getWasm();
121
+ pollIntervalMs = options.pollIntervalMs ?? Number(wasm.wasmConfirmIndexedDefaultPollMs?.() ?? 1000);
122
+ timeoutMs = options.timeoutMs ?? Number(wasm.wasmConfirmIndexedDefaultTimeoutMs?.() ?? 30000);
123
+ query = wasm.wasmConfirmIndexedQuery();
124
+ } catch (err) {
125
+ return {
126
+ indexed: false,
127
+ attempts: 0,
128
+ elapsedMs: 0,
129
+ lastError: `confirm-indexed wasm bindings unavailable: ${err instanceof Error ? err.message : String(err)}`,
130
+ };
131
+ }
132
+ const subgraphUrl =
133
+ options.subgraphUrl ?? `${getSubgraphConfig().relayUrl}/v1/subgraph`;
134
+
135
+ const overrides: Record<string, string> = {
136
+ 'Content-Type': 'application/json',
137
+ };
138
+ if (options.authKeyHex) overrides['Authorization'] = `Bearer ${options.authKeyHex}`;
139
+ const headers = buildRelayHeaders(overrides);
140
+
141
+ const body = JSON.stringify({ query, variables: { id: factId } });
142
+
143
+ const poster =
144
+ options.poster ??
145
+ (async (url, b, h) => {
146
+ const r = await fetch(url, { method: 'POST', headers: h, body: b });
147
+ return { ok: r.ok, status: r.status, text: () => r.text() };
148
+ });
149
+
150
+ const expect = options.expect ?? 'active';
151
+ const start = Date.now();
152
+ let attempts = 0;
153
+ let lastError: string | undefined;
154
+
155
+ while (Date.now() - start < timeoutMs) {
156
+ attempts++;
157
+ try {
158
+ const r = await poster(subgraphUrl, body, headers);
159
+ if (r.ok) {
160
+ const txt = await r.text();
161
+ try {
162
+ // wasmConfirmIndexedParse returns `true` when fact is present AND
163
+ // isActive==true. For `expect: 'inactive'` we invert: a `false`
164
+ // (fact missing OR present-but-inactive) is the resolution signal.
165
+ const isActive = wasm.wasmConfirmIndexedParse(txt);
166
+ const resolved = expect === 'active' ? isActive : !isActive;
167
+ if (resolved) {
168
+ return { indexed: true, attempts, elapsedMs: Date.now() - start };
169
+ }
170
+ } catch (parseErr) {
171
+ lastError = parseErr instanceof Error ? parseErr.message : String(parseErr);
172
+ }
173
+ } else {
174
+ lastError = `HTTP ${r.status}`;
175
+ }
176
+ } catch (err) {
177
+ lastError = err instanceof Error ? err.message : String(err);
178
+ }
179
+ // Sleep before the next attempt — but only if there's still budget.
180
+ const remaining = timeoutMs - (Date.now() - start);
181
+ if (remaining <= 0) break;
182
+ await new Promise((res) => setTimeout(res, Math.min(pollIntervalMs, remaining)));
183
+ }
184
+
185
+ return {
186
+ indexed: false,
187
+ attempts,
188
+ elapsedMs: Date.now() - start,
189
+ lastError,
190
+ };
191
+ }
package/crypto.ts CHANGED
@@ -15,10 +15,18 @@
15
15
  * -> HKDF-SHA256(seed, salt, "openmemory-dedup-v1", 32) -> dedupKey
16
16
  */
17
17
 
18
- // Lazy-load WASM to avoid crash when npm install hasn't finished yet.
18
+ // Lazy-load WASM. Uses createRequire so this module loads cleanly under bare
19
+ // Node ESM — the shipped `dist/index.js` declares `"type":"module"`, where
20
+ // the CJS `require` global is undefined at runtime. Prior to the rc.21 fix
21
+ // this file called bare `require('@totalreclaw/core')` and every consumer
22
+ // died with `require is not defined`. Matches the pattern already used by
23
+ // claims-helper / consolidation / contradiction-sync / digest-sync / pin /
24
+ // retype-setscope. See issue #124.
25
+ import { createRequire } from 'node:module';
26
+ const requireWasm = createRequire(import.meta.url);
19
27
  let _wasm: typeof import('@totalreclaw/core') | null = null;
20
28
  function getWasm() {
21
- if (!_wasm) _wasm = require('@totalreclaw/core');
29
+ if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
22
30
  return _wasm;
23
31
  }
24
32
 
@@ -0,0 +1,226 @@
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
+ * Every outbound request goes through `buildRelayHeaders()` so the
13
+ * `X-TotalReclaw-Client` tag is set + the optional QA-tracing
14
+ * `X-TotalReclaw-Session` tag is forwarded when `TOTALRECLAW_SESSION_ID`
15
+ * is set. See `relay-headers.ts` and internal#127.
16
+ */
17
+ import { buildRelayHeaders } from './relay-headers.js';
18
+ // ---------------------------------------------------------------------------
19
+ // API Client Factory
20
+ // ---------------------------------------------------------------------------
21
+ /**
22
+ * Create an API client bound to a specific TotalReclaw server URL.
23
+ *
24
+ * All methods are async and throw descriptive errors on non-2xx responses.
25
+ */
26
+ export function createApiClient(serverUrl) {
27
+ // Normalise URL -- strip trailing slash.
28
+ const baseUrl = serverUrl.replace(/\/+$/, '');
29
+ // ------------------------------------------------------------------
30
+ // Shared helpers
31
+ // ------------------------------------------------------------------
32
+ /**
33
+ * Throw a descriptive error when the server returns a non-2xx status.
34
+ */
35
+ async function assertOk(res, context) {
36
+ if (res.ok)
37
+ return;
38
+ let body;
39
+ try {
40
+ body = await res.text();
41
+ }
42
+ catch {
43
+ body = '(could not read response body)';
44
+ }
45
+ const hint = res.status === 401
46
+ ? ' Authentication failed. If using a recovery phrase, check that all 12 words are in the correct order and spelled correctly.'
47
+ : '';
48
+ throw new Error(`${context}: HTTP ${res.status} - ${body}${hint}`);
49
+ }
50
+ // ------------------------------------------------------------------
51
+ // Public methods
52
+ // ------------------------------------------------------------------
53
+ return {
54
+ // ---- Registration (unauthenticated) ----
55
+ /**
56
+ * Register a new user.
57
+ *
58
+ * @param authKeyHash Hex-encoded SHA-256 of the auth key (64 chars).
59
+ * @param saltHex Hex-encoded 32-byte salt (64 chars).
60
+ * @returns `{ user_id }` on success.
61
+ */
62
+ async register(authKeyHash, saltHex) {
63
+ const res = await fetch(`${baseUrl}/v1/register`, {
64
+ method: 'POST',
65
+ headers: buildRelayHeaders({ 'Content-Type': 'application/json' }),
66
+ body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
67
+ });
68
+ await assertOk(res, 'register');
69
+ const json = (await res.json());
70
+ if (!json.success && json.error_code !== 'USER_EXISTS') {
71
+ throw new Error(`register: server returned success=false - ${json.error_code}: ${json.error_message}`);
72
+ }
73
+ if (!json.user_id) {
74
+ throw new Error(`register: server did not return user_id (error_code=${json.error_code})`);
75
+ }
76
+ return { user_id: json.user_id };
77
+ },
78
+ // ---- Store (authenticated) ----
79
+ /**
80
+ * Store one or more encrypted facts.
81
+ *
82
+ * @param userId The authenticated user's ID.
83
+ * @param facts Array of `StoreFactPayload` objects.
84
+ * @param authKeyHex Hex-encoded raw auth key (64 chars) for Bearer header.
85
+ */
86
+ async store(userId, facts, authKeyHex) {
87
+ const res = await fetch(`${baseUrl}/v1/store`, {
88
+ method: 'POST',
89
+ headers: buildRelayHeaders({
90
+ 'Content-Type': 'application/json',
91
+ Authorization: `Bearer ${authKeyHex}`,
92
+ }),
93
+ body: JSON.stringify({ user_id: userId, facts }),
94
+ });
95
+ await assertOk(res, 'store');
96
+ const json = (await res.json());
97
+ if (!json.success) {
98
+ throw new Error(`store: server returned success=false - ${json.error_code}: ${json.error_message}`);
99
+ }
100
+ return {
101
+ ids: json.ids ?? [],
102
+ duplicate_ids: json.duplicate_ids,
103
+ };
104
+ },
105
+ // ---- Search (authenticated) ----
106
+ /**
107
+ * Search for facts using blind trapdoors.
108
+ *
109
+ * @param userId The authenticated user's ID.
110
+ * @param trapdoors SHA-256 hex hashes of query tokens.
111
+ * @param maxCandidates Maximum candidates to retrieve.
112
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
113
+ * @returns Array of encrypted search candidates.
114
+ */
115
+ async search(userId, trapdoors, maxCandidates, authKeyHex) {
116
+ const res = await fetch(`${baseUrl}/v1/search`, {
117
+ method: 'POST',
118
+ headers: buildRelayHeaders({
119
+ 'Content-Type': 'application/json',
120
+ Authorization: `Bearer ${authKeyHex}`,
121
+ }),
122
+ body: JSON.stringify({
123
+ user_id: userId,
124
+ trapdoors,
125
+ max_candidates: maxCandidates,
126
+ }),
127
+ });
128
+ await assertOk(res, 'search');
129
+ const json = (await res.json());
130
+ if (!json.success) {
131
+ throw new Error(`search: server returned success=false - ${json.error_code}: ${json.error_message}`);
132
+ }
133
+ return json.results ?? [];
134
+ },
135
+ // ---- Delete (authenticated) ----
136
+ /**
137
+ * Soft-delete a fact by ID.
138
+ *
139
+ * @param factId The fact UUID to delete.
140
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
141
+ */
142
+ async deleteFact(factId, authKeyHex) {
143
+ const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
144
+ method: 'DELETE',
145
+ headers: buildRelayHeaders({
146
+ Authorization: `Bearer ${authKeyHex}`,
147
+ }),
148
+ });
149
+ await assertOk(res, 'deleteFact');
150
+ const json = (await res.json());
151
+ if (!json.success) {
152
+ throw new Error(`deleteFact: server returned success=false - ${json.error_code}: ${json.error_message}`);
153
+ }
154
+ },
155
+ // ---- Batch Delete (authenticated) ----
156
+ /**
157
+ * Batch soft-delete facts by ID list.
158
+ *
159
+ * @param factIds Array of fact UUIDs to delete (max 500).
160
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
161
+ * @returns The number of facts that were actually deleted.
162
+ */
163
+ async batchDelete(factIds, authKeyHex) {
164
+ const res = await fetch(`${baseUrl}/v1/facts/batch-delete`, {
165
+ method: 'POST',
166
+ headers: buildRelayHeaders({
167
+ 'Content-Type': 'application/json',
168
+ Authorization: `Bearer ${authKeyHex}`,
169
+ }),
170
+ body: JSON.stringify({ fact_ids: factIds }),
171
+ });
172
+ await assertOk(res, 'batchDelete');
173
+ const json = (await res.json());
174
+ if (!json.success) {
175
+ throw new Error(`batchDelete: server returned success=false - ${json.error_code}: ${json.error_message}`);
176
+ }
177
+ return json.deleted_count ?? 0;
178
+ },
179
+ // ---- Export (authenticated) ----
180
+ /**
181
+ * Export all active facts (paginated).
182
+ *
183
+ * @param authKeyHex Hex-encoded raw auth key for Bearer header.
184
+ * @param limit Page size (default 1000, max 5000).
185
+ * @param cursor Cursor from previous page (omit for first page).
186
+ * @returns Page of facts with pagination metadata.
187
+ */
188
+ async exportFacts(authKeyHex, limit = 1000, cursor) {
189
+ const params = new URLSearchParams({ limit: String(limit) });
190
+ if (cursor)
191
+ params.set('cursor', cursor);
192
+ const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
193
+ method: 'GET',
194
+ headers: buildRelayHeaders({
195
+ Authorization: `Bearer ${authKeyHex}`,
196
+ }),
197
+ });
198
+ await assertOk(res, 'exportFacts');
199
+ const json = (await res.json());
200
+ if (!json.success) {
201
+ throw new Error(`exportFacts: server returned success=false - ${json.error_code}: ${json.error_message}`);
202
+ }
203
+ return {
204
+ facts: json.facts ?? [],
205
+ cursor: json.cursor,
206
+ has_more: json.has_more ?? false,
207
+ total_count: json.total_count,
208
+ };
209
+ },
210
+ // ---- Health (unauthenticated) ----
211
+ /**
212
+ * Check server health.
213
+ *
214
+ * @returns `true` if the server responds with HTTP 200.
215
+ */
216
+ async health() {
217
+ try {
218
+ const res = await fetch(`${baseUrl}/health`, { method: 'GET' });
219
+ return res.status === 200;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ },
225
+ };
226
+ }