@totalreclaw/totalreclaw 3.3.1-rc.21 → 3.3.1-rc.22

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/CHANGELOG.md CHANGED
@@ -493,7 +493,8 @@ See: `plans/2026-04-22-plugin-3.3.1-provider-agnostic-llm.md` (internal).
493
493
  - `--json` — emits a structured payload (requires `--non-interactive`).
494
494
  - `--mode <generate|restore>` — skip the menu prompt.
495
495
  - `--phrase <12-or-24>` — required for `--mode restore`; `-` reads stdin.
496
- - `--emit-phrase` — opt-in path that includes the plaintext phrase in the
496
+ - `--emit-phrase` — historic opt-in flag (do not invoke via agent shell:
497
+ forbidden by the phrase-safety rule); included plaintext phrase in the
497
498
  JSON payload. Default omits the phrase; the agent should direct the
498
499
  user to read `~/.totalreclaw/credentials.json` in their terminal.
499
500
 
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
3
  description: "Install + set up TotalReclaw encrypted memory for OpenClaw, then use totalreclaw_remember / totalreclaw_recall. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', or any remember / recall request."
4
- version: 3.3.1-rc.11
4
+ version: 3.3.1-rc.22
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
package/claims-helper.ts CHANGED
@@ -96,6 +96,14 @@ export interface BuildClaimInput {
96
96
  sourceAgent: string;
97
97
  /** Creation timestamp. Defaults to now. */
98
98
  extractedAt?: string;
99
+ /**
100
+ * 3.3.1-rc.22 — optional embedding-model id stamped on the claim for
101
+ * forward-compat. Defaults to omitted (callers that already know the
102
+ * active embedder pass it in; legacy paths leave it unset). The field
103
+ * is plugin-scoped — it survives the core validator's strip pass via
104
+ * the same re-attach path used for `schema_version` / `volatility`.
105
+ */
106
+ embeddingModelId?: string;
99
107
  }
100
108
 
101
109
  /**
@@ -112,7 +120,7 @@ export interface BuildClaimInput {
112
120
  * payload (see `subgraph-store.ts::encodeFactProtobuf`).
113
121
  */
114
122
  export function buildCanonicalClaim(input: BuildClaimInput): string {
115
- const { fact, importance, extractedAt } = input;
123
+ const { fact, importance, extractedAt, embeddingModelId } = input;
116
124
 
117
125
  // Defensive: ensure fact.source is always populated before v1 validation.
118
126
  // `applyProvenanceFilterLax` should have set this upstream; this is the
@@ -125,6 +133,7 @@ export function buildCanonicalClaim(input: BuildClaimInput): string {
125
133
  fact: factWithSource,
126
134
  importance,
127
135
  createdAt: extractedAt,
136
+ embeddingModelId,
128
137
  });
129
138
  }
130
139
 
@@ -173,6 +182,14 @@ export interface BuildClaimV1Input {
173
182
  * when provided.
174
183
  */
175
184
  pinStatus?: PinStatus;
185
+ /**
186
+ * 3.3.1-rc.22 — optional embedding-model id stamped on the claim for
187
+ * distillation forward-compat. Survives the core validator strip pass
188
+ * via the same re-attach path used for `schema_version` / `volatility`.
189
+ * When omitted the field is not emitted (legacy claims remain untagged
190
+ * and are read back as "unspecified").
191
+ */
192
+ embeddingModelId?: string;
176
193
  }
177
194
 
178
195
  /**
@@ -257,6 +274,13 @@ export function buildCanonicalClaimV1(input: BuildClaimV1Input): string {
257
274
  if (fact.volatility && (VALID_MEMORY_VOLATILITIES as readonly string[]).includes(fact.volatility)) {
258
275
  canonical.volatility = fact.volatility;
259
276
  }
277
+ // 3.3.1-rc.22 — forward-compat embedder marker. Plugin-only field;
278
+ // survives the core validator via re-attach. Future distillation
279
+ // detects this on read to re-embed selectively without forcing a
280
+ // vault-wide rebuild.
281
+ if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
282
+ canonical.embedding_model_id = input.embeddingModelId;
283
+ }
260
284
 
261
285
  return JSON.stringify(canonical);
262
286
  }
@@ -313,6 +337,11 @@ export interface BuildV1ClaimBlobInput {
313
337
  * non-pin write.
314
338
  */
315
339
  pinStatus?: PinStatus;
340
+ /**
341
+ * 3.3.1-rc.22 — optional embedding-model id stamped on the claim for
342
+ * distillation forward-compat. See `BuildClaimV1Input.embeddingModelId`.
343
+ */
344
+ embeddingModelId?: string;
316
345
  }
317
346
 
318
347
  /**
@@ -374,6 +403,10 @@ export function buildV1ClaimBlob(input: BuildV1ClaimBlobInput): string {
374
403
  if (input.volatility && (VALID_MEMORY_VOLATILITIES as readonly string[]).includes(input.volatility)) {
375
404
  canonical.volatility = input.volatility;
376
405
  }
406
+ // 3.3.1-rc.22 — see `buildCanonicalClaimV1` comment.
407
+ if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
408
+ canonical.embedding_model_id = input.embeddingModelId;
409
+ }
377
410
  return JSON.stringify(canonical);
378
411
  }
379
412
 
@@ -431,6 +464,13 @@ export interface V1BlobReadResult {
431
464
  * when the writer explicitly omitted the field (treated as `"unpinned"`).
432
465
  */
433
466
  pinStatus?: PinStatus;
467
+ /**
468
+ * 3.3.1-rc.22 — embedder identity tag. Absent on claims written by
469
+ * older plugin versions. Forward-compat marker; consumers MAY use it
470
+ * to decide whether a claim's stored embedding matches the active
471
+ * embedder before letting cosine similarity make a relevance call.
472
+ */
473
+ embeddingModelId?: string;
434
474
  }
435
475
 
436
476
  export function readV1Blob(decrypted: string): V1BlobReadResult | null {
@@ -497,6 +537,12 @@ export function readV1Blob(decrypted: string): V1BlobReadResult | null {
497
537
  result.pinStatus = ps;
498
538
  }
499
539
  }
540
+ // 3.3.1-rc.22 — pull the embedder identity tag through. Plugin-only
541
+ // field added by `buildCanonicalClaimV1` / `buildV1ClaimBlob` after
542
+ // core validation.
543
+ if (typeof obj.embedding_model_id === 'string' && obj.embedding_model_id.length > 0) {
544
+ result.embeddingModelId = obj.embedding_model_id;
545
+ }
500
546
 
501
547
  return result;
502
548
  } catch {
package/config.ts CHANGED
@@ -48,12 +48,19 @@ const REMOVED_ENV_VARS = [
48
48
  'TOTALRECLAW_DIGEST_MODE',
49
49
  ] as const;
50
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
+
51
58
  function warnRemovedEnvVars(warn: (msg: string) => void = console.warn): void {
52
59
  const set = REMOVED_ENV_VARS.filter((name) => process.env[name] !== undefined);
53
60
  if (set.length === 0) return;
54
61
  warn(
55
62
  `TotalReclaw: ignoring removed env var(s): ${set.join(', ')}. ` +
56
- `See docs/guides/env-vars-reference.md for the v1 env var surface.`,
63
+ `Migration guide: ${ENV_VARS_REFERENCE_URL}`,
57
64
  );
58
65
  }
59
66
 
@@ -276,6 +283,20 @@ export const CONFIG = {
276
283
  billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
277
284
  cachePath: process.env.TOTALRECLAW_CACHE_PATH || path.join(home, '.totalreclaw', 'cache.enc'),
278
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 || '',
279
300
  } as const;
280
301
 
281
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
+ }
@@ -71,7 +71,7 @@ export function mapTypeToCategory(type) {
71
71
  * payload (see `subgraph-store.ts::encodeFactProtobuf`).
72
72
  */
73
73
  export function buildCanonicalClaim(input) {
74
- const { fact, importance, extractedAt } = input;
74
+ const { fact, importance, extractedAt, embeddingModelId } = input;
75
75
  // Defensive: ensure fact.source is always populated before v1 validation.
76
76
  // `applyProvenanceFilterLax` should have set this upstream; this is the
77
77
  // belt-and-suspenders fallback for explicit tool paths / legacy callers.
@@ -82,6 +82,7 @@ export function buildCanonicalClaim(input) {
82
82
  fact: factWithSource,
83
83
  importance,
84
84
  createdAt: extractedAt,
85
+ embeddingModelId,
85
86
  });
86
87
  }
87
88
  // ---------------------------------------------------------------------------
@@ -173,6 +174,13 @@ export function buildCanonicalClaimV1(input) {
173
174
  if (fact.volatility && VALID_MEMORY_VOLATILITIES.includes(fact.volatility)) {
174
175
  canonical.volatility = fact.volatility;
175
176
  }
177
+ // 3.3.1-rc.22 — forward-compat embedder marker. Plugin-only field;
178
+ // survives the core validator via re-attach. Future distillation
179
+ // detects this on read to re-embed selectively without forcing a
180
+ // vault-wide rebuild.
181
+ if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
182
+ canonical.embedding_model_id = input.embeddingModelId;
183
+ }
176
184
  return JSON.stringify(canonical);
177
185
  }
178
186
  /**
@@ -234,6 +242,10 @@ export function buildV1ClaimBlob(input) {
234
242
  if (input.volatility && VALID_MEMORY_VOLATILITIES.includes(input.volatility)) {
235
243
  canonical.volatility = input.volatility;
236
244
  }
245
+ // 3.3.1-rc.22 — see `buildCanonicalClaimV1` comment.
246
+ if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
247
+ canonical.embedding_model_id = input.embeddingModelId;
248
+ }
237
249
  return JSON.stringify(canonical);
238
250
  }
239
251
  /**
@@ -321,6 +333,12 @@ export function readV1Blob(decrypted) {
321
333
  result.pinStatus = ps;
322
334
  }
323
335
  }
336
+ // 3.3.1-rc.22 — pull the embedder identity tag through. Plugin-only
337
+ // field added by `buildCanonicalClaimV1` / `buildV1ClaimBlob` after
338
+ // core validation.
339
+ if (typeof obj.embedding_model_id === 'string' && obj.embedding_model_id.length > 0) {
340
+ result.embeddingModelId = obj.embedding_model_id;
341
+ }
324
342
  return result;
325
343
  }
326
344
  catch {
package/dist/config.js CHANGED
@@ -44,12 +44,17 @@ const REMOVED_ENV_VARS = [
44
44
  'TOTALRECLAW_CLAIM_FORMAT',
45
45
  'TOTALRECLAW_DIGEST_MODE',
46
46
  ];
47
+ // Migration guide URL — kept as a constant so the regression test can assert
48
+ // the exact link text in the warning. Pointing at GitHub raw-blob is more
49
+ // useful than the relative repo path: operators copying the warning out of
50
+ // stderr usually do not have the repo cloned. rc.22 finding #4.
51
+ export const ENV_VARS_REFERENCE_URL = 'https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/env-vars-reference.md';
47
52
  function warnRemovedEnvVars(warn = console.warn) {
48
53
  const set = REMOVED_ENV_VARS.filter((name) => process.env[name] !== undefined);
49
54
  if (set.length === 0)
50
55
  return;
51
56
  warn(`TotalReclaw: ignoring removed env var(s): ${set.join(', ')}. ` +
52
- `See docs/guides/env-vars-reference.md for the v1 env var surface.`);
57
+ `Migration guide: ${ENV_VARS_REFERENCE_URL}`);
53
58
  }
54
59
  // Emit the warning once at import time. Safe because this module is loaded
55
60
  // exactly once per process.
@@ -254,6 +259,18 @@ export const CONFIG = {
254
259
  billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
255
260
  cachePath: process.env.TOTALRECLAW_CACHE_PATH || path.join(home, '.totalreclaw', 'cache.enc'),
256
261
  openclawWorkspace: path.join(home, '.openclaw', 'workspace'),
262
+ // 3.3.1-rc.22 — lazy embedder bundle cache. The embedder
263
+ // (`@huggingface/transformers` + `onnxruntime-node` + the q4 ONNX
264
+ // model) is no longer shipped inside the plugin tarball; it is fetched
265
+ // on first `embed()` call from a versioned GitHub Release and cached
266
+ // here. Separate path from `cachePath` (encrypted vault cache) so the
267
+ // two never collide. See `embedder-loader.ts`.
268
+ embedderCachePath: process.env.TOTALRECLAW_EMBEDDER_CACHE_PATH || path.join(home, '.totalreclaw', 'embedder'),
269
+ // 3.3.1-rc.22 — override the GitHub-Releases URL templates. Only useful
270
+ // for air-gapped / mirror deployments and self-hosted CI. Empty string
271
+ // falls back to the static defaults baked into the embedder code path.
272
+ embedderBundleUrlTemplate: process.env.TOTALRECLAW_EMBEDDER_BUNDLE_URL || '',
273
+ embedderManifestUrlTemplate: process.env.TOTALRECLAW_EMBEDDER_MANIFEST_URL || '',
257
274
  };
258
275
  /**
259
276
  * Merge a billing-response tuning block with the local fallback values.
@@ -0,0 +1,127 @@
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
+ import { createRequire } from 'node:module';
29
+ import { getSubgraphConfig } from './subgraph-store.js';
30
+ import { buildRelayHeaders } from './relay-headers.js';
31
+ const requireWasm = createRequire(import.meta.url);
32
+ let _wasm = null;
33
+ function getWasm() {
34
+ if (!_wasm)
35
+ _wasm = requireWasm('@totalreclaw/core');
36
+ return _wasm;
37
+ }
38
+ /**
39
+ * Poll the subgraph until the new fact id is indexed-and-active, or the
40
+ * timeout elapses. Returns a result object describing the outcome — never
41
+ * throws on indexer-level transient errors; the caller decides whether to
42
+ * surface a `partial: true` flag based on `result.indexed`.
43
+ *
44
+ * The host's submitBatch already returned a tx hash before this is called,
45
+ * so on `indexed: false` the on-chain write is still acknowledged — just not
46
+ * yet visible in the read API.
47
+ */
48
+ export async function confirmIndexed(factId, options = {}) {
49
+ // WASM bindings may be unavailable (e.g. core@<2.3.0 not yet published).
50
+ // In that case the chain write has still succeeded — confirm step is
51
+ // observational only. Return `indexed: false` so callers surface
52
+ // `partial: true` rather than fail the whole tool invocation.
53
+ let wasm;
54
+ let query;
55
+ let pollIntervalMs;
56
+ let timeoutMs;
57
+ try {
58
+ wasm = getWasm();
59
+ pollIntervalMs = options.pollIntervalMs ?? Number(wasm.wasmConfirmIndexedDefaultPollMs?.() ?? 1000);
60
+ timeoutMs = options.timeoutMs ?? Number(wasm.wasmConfirmIndexedDefaultTimeoutMs?.() ?? 30000);
61
+ query = wasm.wasmConfirmIndexedQuery();
62
+ }
63
+ catch (err) {
64
+ return {
65
+ indexed: false,
66
+ attempts: 0,
67
+ elapsedMs: 0,
68
+ lastError: `confirm-indexed wasm bindings unavailable: ${err instanceof Error ? err.message : String(err)}`,
69
+ };
70
+ }
71
+ const subgraphUrl = options.subgraphUrl ?? `${getSubgraphConfig().relayUrl}/v1/subgraph`;
72
+ const overrides = {
73
+ 'Content-Type': 'application/json',
74
+ };
75
+ if (options.authKeyHex)
76
+ overrides['Authorization'] = `Bearer ${options.authKeyHex}`;
77
+ const headers = buildRelayHeaders(overrides);
78
+ const body = JSON.stringify({ query, variables: { id: factId } });
79
+ const poster = options.poster ??
80
+ (async (url, b, h) => {
81
+ const r = await fetch(url, { method: 'POST', headers: h, body: b });
82
+ return { ok: r.ok, status: r.status, text: () => r.text() };
83
+ });
84
+ const expect = options.expect ?? 'active';
85
+ const start = Date.now();
86
+ let attempts = 0;
87
+ let lastError;
88
+ while (Date.now() - start < timeoutMs) {
89
+ attempts++;
90
+ try {
91
+ const r = await poster(subgraphUrl, body, headers);
92
+ if (r.ok) {
93
+ const txt = await r.text();
94
+ try {
95
+ // wasmConfirmIndexedParse returns `true` when fact is present AND
96
+ // isActive==true. For `expect: 'inactive'` we invert: a `false`
97
+ // (fact missing OR present-but-inactive) is the resolution signal.
98
+ const isActive = wasm.wasmConfirmIndexedParse(txt);
99
+ const resolved = expect === 'active' ? isActive : !isActive;
100
+ if (resolved) {
101
+ return { indexed: true, attempts, elapsedMs: Date.now() - start };
102
+ }
103
+ }
104
+ catch (parseErr) {
105
+ lastError = parseErr instanceof Error ? parseErr.message : String(parseErr);
106
+ }
107
+ }
108
+ else {
109
+ lastError = `HTTP ${r.status}`;
110
+ }
111
+ }
112
+ catch (err) {
113
+ lastError = err instanceof Error ? err.message : String(err);
114
+ }
115
+ // Sleep before the next attempt — but only if there's still budget.
116
+ const remaining = timeoutMs - (Date.now() - start);
117
+ if (remaining <= 0)
118
+ break;
119
+ await new Promise((res) => setTimeout(res, Math.min(pollIntervalMs, remaining)));
120
+ }
121
+ return {
122
+ indexed: false,
123
+ attempts,
124
+ elapsedMs: Date.now() - start,
125
+ lastError,
126
+ };
127
+ }