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

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 (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. package/subgraph-store.ts +109 -16
package/index.ts CHANGED
@@ -80,7 +80,7 @@ import {
80
80
  dedupeByProvider,
81
81
  } from './llm-profile-reader.js';
82
82
  import { LSHHasher } from './lsh.js';
83
- import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS, type RerankerCandidate } from './reranker.js';
83
+ import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS, passesRelevanceGate, type RerankerCandidate } from './reranker.js';
84
84
  import { deduplicateBatch } from './semantic-dedup.js';
85
85
  import {
86
86
  findNearDuplicate,
@@ -135,6 +135,7 @@ import {
135
135
  } from './onboarding-cli.js';
136
136
  import { PluginHotCache, type HotFact } from './hot-cache-wrapper.js';
137
137
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
138
+ import { buildRelayHeaders } from './relay-headers.js';
138
139
  import {
139
140
  readBillingCache,
140
141
  writeBillingCache,
@@ -150,8 +151,11 @@ import {
150
151
  deleteFileIfExists,
151
152
  resolveOnboardingState,
152
153
  writeOnboardingState,
154
+ readPluginVersion,
155
+ cleanupInstallStagingDirs,
153
156
  type OnboardingState,
154
157
  } from './fs-helpers.js';
158
+ import { isRcBuild } from './qa-bug-report.js';
155
159
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
156
160
  import { detectFirstRun, buildWelcomePrepend, type GatewayMode } from './first-run.js';
157
161
  import { buildPairRoutes } from './pair-http.js';
@@ -159,6 +163,19 @@ import { detectGatewayHost } from './gateway-url.js';
159
163
  import { validateMnemonic } from '@scure/bip39';
160
164
  import { wordlist } from '@scure/bip39/wordlists/english.js';
161
165
  import crypto from 'node:crypto';
166
+ import { createRequire } from 'node:module';
167
+ import { fileURLToPath } from 'node:url';
168
+ import * as nodePath from 'node:path';
169
+
170
+ // CJS-style require for the @totalreclaw/core WASM module. We keep this
171
+ // load path lazy (only inside getSmartImportWasm() below) so a partial
172
+ // install of the dependency tree doesn't crash module init. Bare
173
+ // `require()` is a CommonJS global and is undefined under bare Node ESM —
174
+ // the shipped `dist/index.js` declares `"type":"module"`, so calling the
175
+ // global directly emits "require is not defined" at runtime (issue #124).
176
+ // createRequire bridges the gap. Same shape as crypto.ts / lsh.ts /
177
+ // subgraph-store.ts / claims-helper.ts.
178
+ const __cjsRequire = createRequire(import.meta.url);
162
179
 
163
180
  // ---------------------------------------------------------------------------
164
181
  // OpenClaw Plugin API type (defined locally to avoid SDK dependency)
@@ -339,8 +356,17 @@ function buildPairingUrl(
339
356
  // Layers 4 + 5 — auto-detect via gateway-url helper (Tailscale CGNAT, then LAN)
340
357
  else {
341
358
  let detected: ReturnType<typeof detectGatewayHost> = null;
359
+ // issue #110 fix 4 — pass `isDocker` so LAN detection skips
360
+ // 172.16/12 bridge IPs that no external browser can reach.
361
+ let isDocker = false;
342
362
  try {
343
- detected = detectGatewayHost();
363
+ isDocker = isRunningInDocker();
364
+ } catch {
365
+ // Defensive: never block URL building on Docker sniff errors.
366
+ isDocker = false;
367
+ }
368
+ try {
369
+ detected = detectGatewayHost({ isDocker });
344
370
  } catch (err) {
345
371
  api.logger.warn(
346
372
  `TotalReclaw: host autodetect crashed: ${err instanceof Error ? err.message : String(err)} — falling back to localhost`,
@@ -366,9 +392,27 @@ function buildPairingUrl(
366
392
  `Set plugins.entries.totalreclaw.config.publicUrl for remote access.`,
367
393
  );
368
394
  } else {
369
- // Layer 6 — localhost fallback
395
+ // Layer 6 — localhost fallback (or Docker-aware relay-pointer warning)
370
396
  const bind = cfg?.gateway?.bind;
371
- if (bind === 'lan' || bind === 'tailnet') {
397
+ if (isDocker) {
398
+ // issue #110 fix 4: inside Docker the LAN IP is container-internal
399
+ // and useless. Loopback localhost only works for `docker exec`
400
+ // tests. The CORRECT pair URL for Docker is the relay-brokered
401
+ // path served by the `totalreclaw_pair` agent tool (CONFIG.pairMode
402
+ // === 'relay' since rc.11). The CLI-only path here cannot mint a
403
+ // relay session synchronously (the relay handshake needs a WS
404
+ // round-trip), so we emit the loopback URL with a LOUD warning
405
+ // pointing the operator at the agent tool / publicUrl override.
406
+ api.logger.warn(
407
+ `TotalReclaw: Docker container detected — pairing URL falling back to ` +
408
+ `http://localhost:${port}, which is unreachable from the host browser. ` +
409
+ `Use the totalreclaw_pair AGENT TOOL (relay-brokered, universally reachable) ` +
410
+ `instead of the CLI fallback, OR set plugins.entries.totalreclaw.config.publicUrl ` +
411
+ `to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
412
+ `Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
413
+ `air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`,
414
+ );
415
+ } else if (bind === 'lan' || bind === 'tailnet') {
372
416
  api.logger.warn(
373
417
  `TotalReclaw: pairing URL falling back to localhost because gateway.bind=${bind} could not be autodetected. ` +
374
418
  'Set plugins.entries.totalreclaw.config.publicUrl to override.',
@@ -847,11 +891,10 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
847
891
  const billingUrl = CONFIG.serverUrl;
848
892
  const resp = await fetch(`${billingUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
849
893
  method: 'GET',
850
- headers: {
894
+ headers: buildRelayHeaders({
851
895
  'Authorization': `Bearer ${authKeyHex}`,
852
896
  'Accept': 'application/json',
853
- 'X-TotalReclaw-Client': 'openclaw-plugin',
854
- },
897
+ }),
855
898
  });
856
899
  if (resp.ok) {
857
900
  const billingData = await resp.json() as Record<string, unknown>;
@@ -1424,11 +1467,11 @@ async function migrationGqlQuery<T>(
1424
1467
  authKey?: string,
1425
1468
  ): Promise<T | null> {
1426
1469
  try {
1427
- const headers: Record<string, string> = {
1470
+ const overrides: Record<string, string> = {
1428
1471
  'Content-Type': 'application/json',
1429
- 'X-TotalReclaw-Client': 'openclaw-plugin',
1430
1472
  };
1431
- if (authKey) headers['Authorization'] = `Bearer ${authKey}`;
1473
+ if (authKey) overrides['Authorization'] = `Bearer ${authKey}`;
1474
+ const headers = buildRelayHeaders(overrides);
1432
1475
  const response = await fetch(endpoint, {
1433
1476
  method: 'POST',
1434
1477
  headers,
@@ -2269,10 +2312,13 @@ async function handlePluginImportFrom(
2269
2312
  // Smart Import — Two-Pass Pipeline (Profile + Triage)
2270
2313
  // ---------------------------------------------------------------------------
2271
2314
 
2272
- // Lazy-load WASM for smart import functions (same pattern as crypto.ts / subgraph-store.ts).
2315
+ // Lazy-load WASM for smart import functions (same pattern as crypto.ts /
2316
+ // subgraph-store.ts). Goes through __cjsRequire (createRequire(import.meta.url))
2317
+ // declared at the top of the file — bare `require()` is undefined under
2318
+ // pure-ESM Node, see issue #124.
2273
2319
  let _smartImportWasm: typeof import('@totalreclaw/core') | null = null;
2274
2320
  function getSmartImportWasm() {
2275
- if (!_smartImportWasm) _smartImportWasm = require('@totalreclaw/core');
2321
+ if (!_smartImportWasm) _smartImportWasm = __cjsRequire('@totalreclaw/core');
2276
2322
  return _smartImportWasm;
2277
2323
  }
2278
2324
 
@@ -2794,6 +2840,58 @@ const plugin = {
2794
2840
  },
2795
2841
 
2796
2842
  register(api: OpenClawPluginApi) {
2843
+ // ---------------------------------------------------------------
2844
+ // RC-build detection (3.3.1-rc.3)
2845
+ // ---------------------------------------------------------------
2846
+ //
2847
+ // `isRcBuild` reads the plugin's own version string. When true, the
2848
+ // `totalreclaw_report_qa_bug` tool is registered at the end of this
2849
+ // function — stable builds never see it. The version is resolved via
2850
+ // `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
2851
+ let rcMode = false;
2852
+ // Plugin version resolved from package.json once at register time. Reused
2853
+ // by writeOnboardingState callsites below so the `version` field in
2854
+ // state.json tracks the actual shipped plugin version (avoids drift —
2855
+ // e.g. rc.18 finding F4 where a hardcoded `'3.3.1-rc.11'` stayed put
2856
+ // through 7 RC bumps). Fallback `'3.3.0'` matches the prior literal at
2857
+ // the loopback callsite if package.json read fails.
2858
+ let pluginVersion: string | null = null;
2859
+ try {
2860
+ // Resolve our own dist/ directory so `readPluginVersion` can locate
2861
+ // package.json. We use `import.meta.url` + ESM-static stdlib imports
2862
+ // (`fileURLToPath` from `node:url`, `nodePath.dirname` from `node:path`,
2863
+ // both imported at the top of this file). Earlier shape used inline
2864
+ // `require('node:url')` — undefined under bare-ESM Node, broke the
2865
+ // before_agent_start hook in the published rc.20 bundle (issue #124).
2866
+ const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
2867
+ pluginVersion = readPluginVersion(pluginDir);
2868
+ rcMode = isRcBuild(pluginVersion);
2869
+ if (rcMode) {
2870
+ api.logger.info(`TotalReclaw: RC build detected (version=${pluginVersion}). RC-gated tools will be registered.`);
2871
+ }
2872
+
2873
+ // 3.3.1-rc.21 (issue #126 — rc.20 finding F3): clean up
2874
+ // `.openclaw-install-stage-*` siblings left behind by an interrupted
2875
+ // `openclaw plugins install` run. Without cleanup, OpenClaw's plugin
2876
+ // loader auto-discovers the orphan directory on the next gateway
2877
+ // start and registers a duplicate `totalreclaw` plugin (duplicate
2878
+ // hooks, duplicate tools, "duplicate-plugin-id" warning every cycle).
2879
+ // Best-effort — never throws on permission / race failures.
2880
+ try {
2881
+ const removed = cleanupInstallStagingDirs(pluginDir);
2882
+ if (removed.length > 0) {
2883
+ api.logger.info(
2884
+ `TotalReclaw: removed ${removed.length} stale install-staging dir(s) from prior interrupted install: ${removed.join(', ')}`,
2885
+ );
2886
+ }
2887
+ } catch {
2888
+ // Best-effort — already swallowed inside the helper, but keep this
2889
+ // outer try as belt-and-braces against future helper changes.
2890
+ }
2891
+ } catch {
2892
+ rcMode = false;
2893
+ }
2894
+
2797
2895
  // ---------------------------------------------------------------
2798
2896
  // LLM client initialization (auto-detect provider from OpenClaw config)
2799
2897
  // ---------------------------------------------------------------
@@ -2875,6 +2973,12 @@ const plugin = {
2875
2973
  credentialsPath: CREDENTIALS_PATH,
2876
2974
  statePath: CONFIG.onboardingStatePath,
2877
2975
  logger: api.logger,
2976
+ // 3.3.1-rc.18 — wire the pair flow into onboard so the
2977
+ // `--pair-only` flag (issue #95) can delegate to it without
2978
+ // duplicating session-store / URL-builder logic. Same deps
2979
+ // as the standalone `pair` subcommand.
2980
+ pairSessionsPath: CONFIG.pairSessionsPath,
2981
+ renderPairingUrl: (session) => buildPairingUrl(api, session),
2878
2982
  // 3.3.1 — supplied to the non-interactive --json onboard path
2879
2983
  // so the emitted payload includes the derived Smart Account
2880
2984
  // (scope) address. Uses the chain-id default; Pro-tier
@@ -2947,8 +3051,30 @@ const plugin = {
2947
3051
  // Write credentials.json + flip state to 'active' via
2948
3052
  // fs-helpers. This centralizes disk I/O off the
2949
3053
  // pair-http surface (scanner isolation).
3054
+ //
3055
+ // 3.3.1 (internal#130) — derive + persist the Smart Account
3056
+ // address right here so the user can see their scope address
3057
+ // immediately after pair, without waiting for a first chain
3058
+ // write. SA derivation runs locally (WASM deriveEoa + factory
3059
+ // getAddress eth_call); the mnemonic NEVER crosses any new
3060
+ // boundary — it's already on disk in credentials.json and is
3061
+ // consumed by the same `deriveSmartAccountAddress` call the
3062
+ // store/search paths use. Only the derived public address is
3063
+ // persisted to credentials.json (`scope_address`).
3064
+ let scopeAddress: string | undefined;
3065
+ try {
3066
+ scopeAddress = await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
3067
+ } catch (err) {
3068
+ // Best-effort. If chain RPC is unreachable at pair time, the
3069
+ // status tool re-tries derivation lazily on next call —
3070
+ // fall through and write credentials.json without it.
3071
+ api.logger.warn(
3072
+ `pair: scope_address derivation failed (will retry lazily): ${err instanceof Error ? err.message : String(err)}`,
3073
+ );
3074
+ }
2950
3075
  const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
2951
- const next = { ...creds, mnemonic };
3076
+ const next: typeof creds = { ...creds, mnemonic };
3077
+ if (scopeAddress) next.scope_address = scopeAddress;
2952
3078
  if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
2953
3079
  return { state: 'error', error: 'credentials_write_failed' };
2954
3080
  }
@@ -2961,7 +3087,7 @@ const plugin = {
2961
3087
  onboardingState: 'active',
2962
3088
  createdBy: 'generate',
2963
3089
  credentialsCreatedAt: new Date().toISOString(),
2964
- version: '3.3.0',
3090
+ version: pluginVersion ?? '3.3.0',
2965
3091
  });
2966
3092
  return { state: 'active' };
2967
3093
  },
@@ -3496,14 +3622,19 @@ const plugin = {
3496
3622
  };
3497
3623
  }
3498
3624
 
3499
- // 6b. Cosine similarity threshold gate — skip results when the
3500
- // best match is below the minimum relevance threshold.
3501
- const maxCosine = Math.max(
3502
- ...reranked.map((r) => r.cosineSimilarity ?? 0),
3503
- );
3504
- if (maxCosine < COSINE_THRESHOLD) {
3625
+ // 6b. Relevance gate — surface results when EITHER the top match
3626
+ // clears the cosine threshold OR every meaningful query token
3627
+ // appears in the top result's text (lexical override).
3628
+ // Issue #116 (rc.18 finding F1): short queries like
3629
+ // "favorite color" produce embeddings with low cosine sim
3630
+ // against the local Harrier-OSS-270m model even when the
3631
+ // stored fact text contains every query token.
3632
+ if (!passesRelevanceGate(params.query, reranked, COSINE_THRESHOLD)) {
3633
+ const maxCosine = Math.max(
3634
+ ...reranked.map((r) => r.cosineSimilarity ?? 0),
3635
+ );
3505
3636
  api.logger.info(
3506
- `Recall: cosine threshold gate filtered results (max=${maxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
3637
+ `Recall: relevance gate filtered results (max cosine=${maxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD}, no lexical override)`,
3507
3638
  );
3508
3639
  return {
3509
3640
  content: [{ type: 'text', text: 'No relevant memories found for this query.' }],
@@ -3731,11 +3862,10 @@ const plugin = {
3731
3862
 
3732
3863
  const res = await fetch(`${relayUrl}/v1/subgraph`, {
3733
3864
  method: 'POST',
3734
- headers: {
3865
+ headers: buildRelayHeaders({
3735
3866
  'Content-Type': 'application/json',
3736
- 'X-TotalReclaw-Client': 'openclaw-plugin',
3737
3867
  ...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
3738
- },
3868
+ }),
3739
3869
  body: JSON.stringify({ query, variables }),
3740
3870
  });
3741
3871
 
@@ -3871,11 +4001,10 @@ const plugin = {
3871
4001
  const walletAddr = subgraphOwner || userId || '';
3872
4002
  const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
3873
4003
  method: 'GET',
3874
- headers: {
4004
+ headers: buildRelayHeaders({
3875
4005
  'Authorization': `Bearer ${authKeyHex}`,
3876
4006
  'Accept': 'application/json',
3877
- 'X-TotalReclaw-Client': 'openclaw-plugin',
3878
- },
4007
+ }),
3879
4008
  });
3880
4009
 
3881
4010
  if (!response.ok) {
@@ -3900,6 +4029,37 @@ const plugin = {
3900
4029
  checked_at: Date.now(),
3901
4030
  });
3902
4031
 
4032
+ // 3.3.1 (internal#130) — surface the Smart Account / scope
4033
+ // address so the user can do subgraph queries, BaseScan
4034
+ // lookups, and cross-client portability checks BEFORE any
4035
+ // chain write completes. Resolution priority:
4036
+ // 1. In-memory `subgraphOwner` (already derived earlier).
4037
+ // 2. credentials.json `scope_address` (persisted at pair).
4038
+ // 3. Lazy derive now from the loaded mnemonic + cache it
4039
+ // back to credentials.json so the next call is free.
4040
+ let scopeAddress: string | undefined =
4041
+ subgraphOwner ?? undefined;
4042
+ if (!scopeAddress) {
4043
+ try {
4044
+ const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
4045
+ if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
4046
+ scopeAddress = credsCache.scope_address;
4047
+ } else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
4048
+ scopeAddress = await deriveSmartAccountAddress(
4049
+ credsCache.mnemonic,
4050
+ CONFIG.chainId,
4051
+ );
4052
+ if (scopeAddress) {
4053
+ writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
4054
+ }
4055
+ }
4056
+ } catch (deriveErr) {
4057
+ api.logger.warn(
4058
+ `totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
4059
+ );
4060
+ }
4061
+ }
4062
+
3903
4063
  const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
3904
4064
  const lines: string[] = [
3905
4065
  `Tier: ${tierLabel}`,
@@ -3908,13 +4068,21 @@ const plugin = {
3908
4068
  if (freeWritesResetAt) {
3909
4069
  lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
3910
4070
  }
4071
+ if (scopeAddress) {
4072
+ lines.push(`Smart Account: ${scopeAddress}`);
4073
+ }
3911
4074
  if (tier !== 'pro') {
3912
4075
  lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
3913
4076
  }
3914
4077
 
3915
4078
  return {
3916
4079
  content: [{ type: 'text', text: lines.join('\n') }],
3917
- details: { tier, free_writes_used: freeWritesUsed, free_writes_limit: freeWritesLimit },
4080
+ details: {
4081
+ tier,
4082
+ free_writes_used: freeWritesUsed,
4083
+ free_writes_limit: freeWritesLimit,
4084
+ scope_address: scopeAddress,
4085
+ },
3918
4086
  };
3919
4087
  } catch (err: unknown) {
3920
4088
  const message = err instanceof Error ? err.message : String(err);
@@ -4637,11 +4805,10 @@ const plugin = {
4637
4805
 
4638
4806
  const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
4639
4807
  method: 'POST',
4640
- headers: {
4808
+ headers: buildRelayHeaders({
4641
4809
  'Authorization': `Bearer ${authKeyHex}`,
4642
4810
  'Content-Type': 'application/json',
4643
- 'X-TotalReclaw-Client': 'openclaw-plugin',
4644
- },
4811
+ }),
4645
4812
  body: JSON.stringify({
4646
4813
  wallet_address: walletAddr,
4647
4814
  tier: 'pro',
@@ -4725,11 +4892,10 @@ const plugin = {
4725
4892
  `${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`,
4726
4893
  {
4727
4894
  method: 'GET',
4728
- headers: {
4895
+ headers: buildRelayHeaders({
4729
4896
  'Authorization': `Bearer ${authKeyHex}`,
4730
4897
  'Content-Type': 'application/json',
4731
- 'X-TotalReclaw-Client': 'openclaw-plugin',
4732
- },
4898
+ }),
4733
4899
  },
4734
4900
  );
4735
4901
  if (!billingResp.ok) {
@@ -4864,301 +5030,66 @@ const plugin = {
4864
5030
  );
4865
5031
 
4866
5032
  // ---------------------------------------------------------------
4867
- // Tool: totalreclaw_setup (DEPRECATED in 3.2.0)
5033
+ // Tools: totalreclaw_setup + totalreclaw_onboarding_start
5034
+ // REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
4868
5035
  // ---------------------------------------------------------------
4869
5036
  //
4870
- // Pre-3.2.0 behaviour: auto-generate a mnemonic + return it in the tool
4871
- // response body so the LLM surfaces it to the user. That path shipped
4872
- // the recovery phrase to the LLM provider's logs — incompatible with
4873
- // TotalReclaw's "server cannot read your memories" pitch. 3.2.0
4874
- // replaces it with a pointer-only stub.
5037
+ // rc.4 left these two registrations in place as *neutered* stubs
5038
+ // ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
5039
+ // and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
5040
+ // was already pointer-only. Neither path could leak a phrase in
5041
+ // rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
5042
+ // regression surface: any future patch that re-enables phrase
5043
+ // acceptance (e.g. a flag-driven "power-user" path) would silently
5044
+ // re-open the leak, and their mere presence in the tool registry
5045
+ // keeps signalling to agents that "phrase handling happens here".
4875
5046
  //
4876
- // Kept registered (rather than deleted) for back-compat — LLMs that
4877
- // learned the old tool name from training data won't silently succeed
4878
- // if the user asks them to set up memory. They'll call this tool,
4879
- // receive a pointer to `openclaw totalreclaw onboard`, and the flow
4880
- // continues on the user's TTY.
5047
+ // Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
5048
+ // facilitated setup surface is ``totalreclaw_pair`` (browser-side
5049
+ // crypto keeps the phrase out of the LLM round-trip by construction).
5050
+ // rc.5 deletes both registrations outright. The underlying CLI
5051
+ // wizard (``openclaw totalreclaw onboard``) is unchanged — users
5052
+ // run it in their own terminal, outside any agent shell.
4881
5053
  //
4882
- // The `recovery_phrase` param is kept in the schema so existing tool
4883
- // calls parse but ANY phrase the caller passes is rejected, and the
4884
- // tool NEVER writes credentials.json. All real setup happens in the
4885
- // CLI wizard.
4886
- api.registerTool(
4887
- {
4888
- name: 'totalreclaw_setup',
4889
- label: 'TotalReclaw setup (deprecated — redirect to CLI)',
4890
- description:
4891
- 'DEPRECATED in 3.2.0. This tool no longer accepts recovery phrases or performs ' +
4892
- 'setup. It returns a pointer to `openclaw totalreclaw onboard` — the secure CLI ' +
4893
- 'wizard that runs on the user\'s terminal so the phrase never touches the LLM ' +
4894
- 'provider. Prefer calling `totalreclaw_onboarding_start` for the same pointer.',
4895
- parameters: {
4896
- type: 'object',
4897
- properties: {
4898
- recovery_phrase: {
4899
- type: 'string',
4900
- description:
4901
- 'Legacy parameter — IGNORED in 3.2.0. If provided, the tool returns a ' +
4902
- 'security warning explaining that phrases must never be pasted through ' +
4903
- 'chat. Use the `openclaw totalreclaw onboard` CLI wizard to import an ' +
4904
- 'existing phrase safely.',
4905
- },
4906
- },
4907
- additionalProperties: false,
4908
- },
4909
- async execute(_toolCallId: string, params: { recovery_phrase?: string }) {
4910
- // Phrase-passing is a security boundary violation in 3.2.0. Reject
4911
- // with a message that explains WHY — the LLM might try again with
4912
- // a different shape otherwise.
4913
- if (typeof params?.recovery_phrase === 'string' && params.recovery_phrase.trim().length > 0) {
4914
- api.logger.warn(
4915
- 'totalreclaw_setup: rejected phrase-passing call (3.2.0 deprecation).',
4916
- );
4917
- return {
4918
- content: [{
4919
- type: 'text',
4920
- text:
4921
- 'For security, TotalReclaw no longer accepts a recovery phrase through ' +
4922
- 'chat. Pasting a phrase into this tool would ship it to the LLM provider, ' +
4923
- 'which defeats the whole point of end-to-end encryption.\n\n' +
4924
- 'Ask the user to open a terminal on their machine and run:\n\n' +
4925
- ' openclaw totalreclaw onboard\n\n' +
4926
- 'The wizard imports an existing phrase via a hidden stdin prompt that ' +
4927
- 'never touches the LLM, the transcript, or the network.',
4928
- }],
4929
- };
4930
- }
4931
-
4932
- // No-arg call against an already-active state: confirm + move on.
4933
- const state = resolveOnboardingState(CREDENTIALS_PATH, CONFIG.onboardingStatePath);
4934
- if (state.onboardingState === 'active') {
4935
- return {
4936
- content: [{
4937
- type: 'text',
4938
- text:
4939
- 'TotalReclaw is already set up and active on this machine. Memory tools ' +
4940
- 'are unblocked — you can call `totalreclaw_remember` and `totalreclaw_recall` ' +
4941
- 'directly. If the user wants to rotate phrases, have them delete ' +
4942
- '`~/.totalreclaw/credentials.json` and run `openclaw totalreclaw onboard` again.',
4943
- }],
4944
- };
4945
- }
4946
-
4947
- // Fresh state, no phrase: redirect to the CLI wizard.
4948
- return {
4949
- content: [{
4950
- type: 'text',
4951
- text:
4952
- 'TotalReclaw setup must run on the user\'s local terminal so the recovery ' +
4953
- 'phrase never touches the LLM. Ask the user to open a terminal and run:\n\n' +
4954
- ' openclaw totalreclaw onboard\n\n' +
4955
- 'The wizard walks through generate-new-phrase or import-existing-phrase. ' +
4956
- 'After it completes, memory tools become available automatically. See the ' +
4957
- '`totalreclaw_onboarding_start` tool for the same pointer in a more ' +
4958
- 'discoverable shape.',
4959
- }],
4960
- };
4961
- },
4962
- },
4963
- { name: 'totalreclaw_setup' },
4964
- );
4965
-
4966
- // ---------------------------------------------------------------
4967
- // Tool: totalreclaw_onboarding_start (3.2.0 pointer-only tool)
4968
- // ---------------------------------------------------------------
5054
+ // Audit assertion: ``phrase-safety-registry.test.ts`` asserts
5055
+ // neither name is present in the ``api.registerTool`` call list.
5056
+ // Re-adding either fails CI.
4969
5057
  //
4970
- // When the user asks the LLM to set up TotalReclaw, this tool directs
4971
- // them to the CLI wizard. The response body is a non-secret pointer —
4972
- // it NEVER contains a recovery phrase — so it can safely flow through
4973
- // the LLM provider and the transcript.
4974
- api.registerTool(
4975
- {
4976
- name: 'totalreclaw_onboarding_start',
4977
- label: 'TotalReclaw — start onboarding',
4978
- description:
4979
- 'Call this when the user wants to set up TotalReclaw memory or asks about ' +
4980
- 'enabling memory features. This tool does NOT generate, display, or accept ' +
4981
- 'a recovery phrase — it returns a short pointer that tells the user to run ' +
4982
- 'the onboarding wizard in their local terminal. All phrase handling happens ' +
4983
- 'outside the LLM. If TotalReclaw is already active, the tool returns a ' +
4984
- 'confirmation.',
4985
- parameters: {
4986
- type: 'object',
4987
- properties: {},
4988
- additionalProperties: false,
4989
- },
4990
- async execute() {
4991
- const state = resolveOnboardingState(CREDENTIALS_PATH, CONFIG.onboardingStatePath);
4992
- if (state.onboardingState === 'active') {
4993
- return {
4994
- content: [{
4995
- type: 'text',
4996
- text:
4997
- 'TotalReclaw is already set up on this machine. Your encryption keys are ' +
4998
- 'ready — `totalreclaw_remember`, `totalreclaw_recall`, and the other memory ' +
4999
- 'tools are unblocked. Run `openclaw totalreclaw status` in a terminal for ' +
5000
- 'more detail.',
5001
- }],
5002
- };
5003
- }
5004
- return {
5005
- content: [{
5006
- type: 'text',
5007
- text:
5008
- 'TotalReclaw onboarding requires a local terminal so your recovery phrase ' +
5009
- 'never touches the LLM provider. On the same machine as your OpenClaw ' +
5010
- 'gateway, open a terminal and run:\n\n' +
5011
- ' openclaw totalreclaw onboard\n\n' +
5012
- 'The wizard will ask whether you want to generate a new phrase or import an ' +
5013
- 'existing TotalReclaw phrase. Both paths display/accept the phrase only on ' +
5014
- 'your terminal — nothing crosses the network. After the wizard completes, ' +
5015
- 'come back here and I\'ll be able to use `totalreclaw_remember` and ' +
5016
- '`totalreclaw_recall`.',
5017
- }],
5018
- };
5019
- },
5020
- },
5021
- { name: 'totalreclaw_onboarding_start' },
5022
- );
5058
+ // Historical tombstone (so LLM-assisted contributors don't re-add
5059
+ // the former shape from training-data memory): rc.4 registered two
5060
+ // tools by the names "totalreclaw_setup" and
5061
+ // "totalreclaw_onboarding_start" as pointer-only stubs. Both were
5062
+ // deleted in rc.5. Do not re-introduce.
5023
5063
 
5024
5064
  // ---------------------------------------------------------------
5025
- // Tool: totalreclaw_onboard (3.3.1-rc.2 — agent-callable onboard)
5065
+ // Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
5066
+ //
5067
+ // rc.3 shipped a `totalreclaw_onboard` agent tool that generated a
5068
+ // fresh BIP-39 mnemonic in-process, wrote it to credentials.json,
5069
+ // and returned `{scope_address, credentials_path}` to the agent.
5070
+ // `emitPhrase: false` kept the mnemonic OUT of the tool's return
5071
+ // payload, but NOTHING ARCHITECTURALLY PREVENTED leakage — a future
5072
+ // patch could regress the flag, a different code path could echo
5073
+ // the mnemonic in a log/error message the agent captures, or the
5074
+ // mere existence of the tool implied to agents that "generating a
5075
+ // phrase here is fine" (it isn't).
5026
5076
  //
5027
- // Lets the agent drive the non-interactive onboard flow without
5028
- // shelling out to `openclaw totalreclaw onboard` (subprocess work
5029
- // is not available inside the plugin runtime and the OpenClaw
5030
- // security scanner blocks the standard subprocess module).
5077
+ // Per ``project_phrase_safety_rule.md``
5078
+ // (memory file in p-diogo/totalreclaw-internal absolute rule:
5079
+ // "recovery phrase MUST NEVER cross the LLM context in ANY form"),
5080
+ // phrase-generating agent tools are forbidden. The ONLY approved
5081
+ // agent-facilitated setup surface is ``totalreclaw_pair`` (browser-
5082
+ // side crypto keeps the phrase out of the LLM round-trip by
5083
+ // construction). The underlying ``runNonInteractiveOnboard`` code
5084
+ // path is still reachable via the CLI ``openclaw totalreclaw onboard``
5085
+ // — that path runs in the user's own terminal, OUTSIDE any agent
5086
+ // shell, so phrase stdout never feeds back into LLM context.
5031
5087
  //
5032
- // Security envelope (same as the CLI path):
5033
- // - NEVER accepts a recovery phrase through chat (mode=restore
5034
- // is rejected here that still requires the local CLI wizard).
5035
- // - The generated phrase is written to ~/.totalreclaw/credentials.json
5036
- // (mode 0600) by `runNonInteractiveOnboard`. We explicitly pass
5037
- // `emitPhrase: false` so the phrase NEVER appears in the tool
5038
- // return value.
5039
- // - Returns only: scope_address + credentials_path + ok/error.
5088
+ // Audit assertion: ``tool-gating.test.ts`` enforces the removal
5089
+ // any future re-registration of ``totalreclaw_onboard`` (or any
5090
+ // phrase-generating variant like ``totalreclaw_onboard_generate``,
5091
+ // ``totalreclaw_restore_phrase``) fails CI.
5040
5092
  // ---------------------------------------------------------------
5041
- api.registerTool(
5042
- {
5043
- name: 'totalreclaw_onboard',
5044
- label: 'Onboard (generate new recovery phrase)',
5045
- description:
5046
- 'Generate a NEW TotalReclaw recovery phrase on this machine without the user ' +
5047
- 'leaving chat. The phrase is written ONLY to ~/.totalreclaw/credentials.json (mode ' +
5048
- '0600) and NEVER returned through this tool — the response contains just the derived ' +
5049
- 'Smart Account (scope) address and the credentials path so the user can retrieve ' +
5050
- 'their phrase locally (e.g. `cat ~/.totalreclaw/credentials.json | jq -r .mnemonic`).\n\n' +
5051
- 'Use when a fresh user asks you to set up TotalReclaw or enable memory. Refuses if ' +
5052
- 'onboarding is already active (the user can delete the credentials file to re-onboard, ' +
5053
- 'but we will NOT silently overwrite). For RESTORE (import an existing phrase), tell ' +
5054
- 'the user to run `openclaw totalreclaw onboard --mode restore` in their local ' +
5055
- 'terminal — this tool refuses to accept phrases through chat.',
5056
- parameters: {
5057
- type: 'object',
5058
- properties: {
5059
- mode: {
5060
- type: 'string',
5061
- enum: ['generate'],
5062
- description:
5063
- 'Only "generate" is supported via this tool (creates a fresh 12-word BIP-39 ' +
5064
- 'phrase). "restore" requires the local CLI wizard because pasting a phrase ' +
5065
- 'through chat ships it to the LLM provider, defeating end-to-end encryption.',
5066
- default: 'generate',
5067
- },
5068
- },
5069
- additionalProperties: false,
5070
- },
5071
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5072
- const mode = params?.mode;
5073
- if (mode !== undefined && mode !== 'generate') {
5074
- return {
5075
- content: [{
5076
- type: 'text',
5077
- text:
5078
- 'Only mode="generate" is supported through chat. For RESTORE (import an ' +
5079
- 'existing recovery phrase), ask the user to run `openclaw totalreclaw onboard ' +
5080
- '--mode restore` in their local terminal — the phrase is read from stdin and ' +
5081
- 'never touches the LLM provider or the transcript.',
5082
- }],
5083
- };
5084
- }
5085
- try {
5086
- const result: NonInteractiveOnboardResult = await runNonInteractiveOnboard({
5087
- credentialsPath: CREDENTIALS_PATH,
5088
- statePath: CONFIG.onboardingStatePath,
5089
- mode: 'generate',
5090
- emitPhrase: false, // NEVER include the phrase in agent-visible output.
5091
- deriveScopeAddress: async (mnemonic: string) => {
5092
- try {
5093
- return await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
5094
- } catch (err) {
5095
- api.logger.warn(
5096
- `totalreclaw_onboard: scope-address derivation failed: ${
5097
- err instanceof Error ? err.message : String(err)
5098
- }`,
5099
- );
5100
- return undefined;
5101
- }
5102
- },
5103
- });
5104
- if (!result.ok) {
5105
- // already-active, write-failed, etc. Never leaks the phrase.
5106
- api.logger.info(`totalreclaw_onboard: ok=false error=${result.error}`);
5107
- return {
5108
- content: [{
5109
- type: 'text',
5110
- text:
5111
- result.error === 'already-active'
5112
- ? 'TotalReclaw is already set up on this machine. Memory tools are unblocked. To re-onboard, delete ~/.totalreclaw/credentials.json first.'
5113
- : `Onboard failed: ${result.error_detail ?? result.error}`,
5114
- }],
5115
- details: {
5116
- ok: false,
5117
- error: result.error,
5118
- action: result.action,
5119
- },
5120
- };
5121
- }
5122
- api.logger.info(
5123
- `totalreclaw_onboard: generated phrase, scope=${result.scope_address?.slice(0, 10) ?? 'unknown'}…, credentials=${result.credentials_path}`,
5124
- );
5125
- // Crucially: the mnemonic field is NEVER emitted in details
5126
- // (emitPhrase=false enforces that on the result object).
5127
- return {
5128
- content: [{
5129
- type: 'text',
5130
- text:
5131
- `TotalReclaw setup complete. A new 12-word recovery phrase was generated and ` +
5132
- `saved to ${result.credentials_path} (mode 0600). ` +
5133
- (result.scope_address
5134
- ? `Your on-chain scope (Smart Account) address is ${result.scope_address}. `
5135
- : '') +
5136
- `To view your recovery phrase, run on the user's local terminal:\n\n` +
5137
- ` cat ${result.credentials_path} | jq -r .mnemonic\n\n` +
5138
- `IMPORTANT: the recovery phrase is the ONLY way to recover memories on another ` +
5139
- `device. Tell the user to store it safely — a password manager or a paper backup. ` +
5140
- `TotalReclaw cannot recover it if lost.`,
5141
- }],
5142
- details: {
5143
- ok: true,
5144
- action: 'generate',
5145
- scope_address: result.scope_address,
5146
- credentials_path: result.credentials_path,
5147
- // Deliberately NO mnemonic field.
5148
- },
5149
- };
5150
- } catch (err: unknown) {
5151
- const message = err instanceof Error ? err.message : String(err);
5152
- api.logger.error(`totalreclaw_onboard failed: ${message}`);
5153
- return {
5154
- content: [{ type: 'text', text: `Onboard failed: ${humanizeError(message)}` }],
5155
- details: { ok: false, error: 'unexpected', error_detail: message },
5156
- };
5157
- }
5158
- },
5159
- },
5160
- { name: 'totalreclaw_onboard' },
5161
- );
5162
5093
 
5163
5094
  // ---------------------------------------------------------------
5164
5095
  // Tool: totalreclaw_pair (3.3.1-rc.2 — agent-callable pair-generate)
@@ -5179,10 +5110,12 @@ const plugin = {
5179
5110
  '6-digit PIN, and an ASCII QR code that the agent relays to the user. The recovery ' +
5180
5111
  'phrase itself is generated/entered in the BROWSER and uploaded end-to-end encrypted ' +
5181
5112
  'to this gateway — it NEVER touches the LLM provider or the chat transcript.\n\n' +
5182
- 'Use when a user wants to set up TotalReclaw on a machine they don\'t have terminal ' +
5183
- 'access to (a VPS, a friend\'s computer), or when they prefer a phone-mediated flow. ' +
5184
- 'For local-machine setup with a terminal, prefer `totalreclaw_onboard` (generate) or ' +
5185
- '`totalreclaw_onboarding_start` (pointer to local CLI for restore).',
5113
+ 'This is the CANONICAL agent-facilitated setup surface use it whenever the user ' +
5114
+ 'asks you to set up TotalReclaw, regardless of whether they have terminal access. ' +
5115
+ 'Browser-side crypto keeps the recovery phrase out of the LLM context entirely. ' +
5116
+ 'If a user explicitly prefers local-terminal setup with no browser, point them at ' +
5117
+ '`totalreclaw_onboarding_start` (a pointer to the CLI wizard they run on their own ' +
5118
+ 'terminal, NOT through your shell tool).',
5186
5119
  parameters: {
5187
5120
  type: 'object',
5188
5121
  properties: {
@@ -5202,18 +5135,117 @@ const plugin = {
5202
5135
  const rawMode = params?.mode;
5203
5136
  const mode: 'generate' | 'import' =
5204
5137
  rawMode === 'import' ? 'import' : 'generate';
5138
+ const pairMode = CONFIG.pairMode;
5205
5139
  try {
5206
- const { createPairSession } = await import('./pair-session-store.js');
5207
- const { generateGatewayKeypair } = await import('./pair-crypto.js');
5140
+ // 3.3.1-rc.11 relay-brokered pair by default (universal reachability).
5141
+ // `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
5142
+ // for air-gapped / self-hosted setups. Both paths return the same
5143
+ // tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
5144
+ // only the URL origin differs.
5145
+ let url: string;
5146
+ let pin: string;
5147
+ let sidOrToken: string;
5148
+ let expiresAtMs: number;
5149
+ let localSession: import('./pair-session-store.js').PairSession | undefined;
5150
+
5151
+ if (pairMode === 'relay') {
5152
+ const { openRemotePairSession, awaitPhraseUpload } = await import(
5153
+ './pair-remote-client.js'
5154
+ );
5155
+ const remoteSession = await openRemotePairSession({
5156
+ relayBaseUrl: CONFIG.pairRelayUrl,
5157
+ mode: mode === 'generate' ? 'generate' : 'import',
5158
+ });
5159
+ url = remoteSession.url;
5160
+ pin = remoteSession.pin;
5161
+ sidOrToken = remoteSession.token;
5162
+ // Relay sends ISO-8601; convert to ms for tool payload parity.
5163
+ const parsed = Date.parse(remoteSession.expiresAt);
5164
+ expiresAtMs = Number.isFinite(parsed)
5165
+ ? parsed
5166
+ : Date.now() + 5 * 60_000;
5167
+ // Background task — writes credentials.json + flips state when
5168
+ // the browser completes the flow. Tool handler returns
5169
+ // immediately so the agent can tell the user the URL + PIN.
5170
+ void (async () => {
5171
+ try {
5172
+ await awaitPhraseUpload(remoteSession, {
5173
+ phraseValidator: (p: string) =>
5174
+ validateMnemonic(p, wordlist),
5175
+ completePairing: async ({ mnemonic }) => {
5176
+ try {
5177
+ // 3.3.1 (internal#130) — derive + persist the
5178
+ // Smart Account address now so the user can see
5179
+ // it immediately after pair, before any chain
5180
+ // write. Mnemonic stays in this scope (already
5181
+ // on disk in credentials.json); only the
5182
+ // derived public scope_address is added.
5183
+ let scopeAddress: string | undefined;
5184
+ try {
5185
+ scopeAddress = await deriveSmartAccountAddress(
5186
+ mnemonic,
5187
+ CONFIG.chainId,
5188
+ );
5189
+ } catch (deriveErr) {
5190
+ api.logger.warn(
5191
+ `totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
5192
+ );
5193
+ }
5194
+ const creds =
5195
+ loadCredentialsJson(CREDENTIALS_PATH) ?? {};
5196
+ const next: typeof creds = { ...creds, mnemonic };
5197
+ if (scopeAddress) next.scope_address = scopeAddress;
5198
+ if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
5199
+ return { state: 'error', error: 'credentials_write_failed' };
5200
+ }
5201
+ setRecoveryPhraseOverride(mnemonic);
5202
+ writeOnboardingState(CONFIG.onboardingStatePath, {
5203
+ onboardingState: 'active',
5204
+ createdBy: mode === 'generate' ? 'generate' : 'import',
5205
+ credentialsCreatedAt: new Date().toISOString(),
5206
+ version: pluginVersion ?? '3.3.0',
5207
+ });
5208
+ api.logger.info(
5209
+ `totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`,
5210
+ );
5211
+ return { state: 'active' };
5212
+ } catch (err: unknown) {
5213
+ const msg = err instanceof Error ? err.message : String(err);
5214
+ api.logger.error(
5215
+ `totalreclaw_pair(relay): completePairing failed: ${msg}`,
5216
+ );
5217
+ return { state: 'error', error: msg };
5218
+ }
5219
+ },
5220
+ });
5221
+ } catch (bgErr: unknown) {
5222
+ // Expected on TTL expiry / user-aborts — log at warn, not error.
5223
+ const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
5224
+ api.logger.warn(
5225
+ `totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`,
5226
+ );
5227
+ }
5228
+ })();
5229
+ } else {
5230
+ // Local loopback path (rc.10 behaviour).
5231
+ const { createPairSession } = await import('./pair-session-store.js');
5232
+ const { generateGatewayKeypair } = await import('./pair-crypto.js');
5233
+ const kp = generateGatewayKeypair();
5234
+ const session = await createPairSession(CONFIG.pairSessionsPath, {
5235
+ mode,
5236
+ operatorContext: { channel: 'agent' },
5237
+ rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5238
+ rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5239
+ });
5240
+ url = buildPairingUrl(api, session);
5241
+ pin = session.secondaryCode;
5242
+ sidOrToken = session.sid;
5243
+ expiresAtMs = session.expiresAtMs;
5244
+ localSession = session;
5245
+ }
5246
+
5247
+ // QR renderers — same for both modes; input is the URL string.
5208
5248
  const { defaultRenderQr } = await import('./pair-cli.js');
5209
- const kp = generateGatewayKeypair();
5210
- const session = await createPairSession(CONFIG.pairSessionsPath, {
5211
- mode,
5212
- operatorContext: { channel: 'agent' },
5213
- rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5214
- rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5215
- });
5216
- const url = buildPairingUrl(api, session);
5217
5249
  const qrAscii = await new Promise<string>((resolve) => {
5218
5250
  let settled = false;
5219
5251
  const t = setTimeout(() => {
@@ -5236,16 +5268,40 @@ const plugin = {
5236
5268
  resolve('');
5237
5269
  }
5238
5270
  });
5271
+
5272
+ // 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
5273
+ let qrPngB64 = '';
5274
+ let qrUnicode = '';
5275
+ try {
5276
+ const { encodePng, encodeUnicode } = await import('./pair-qr.js');
5277
+ const [pngBuf, uni] = await Promise.all([
5278
+ encodePng(url),
5279
+ encodeUnicode(url),
5280
+ ]);
5281
+ qrPngB64 = pngBuf.toString('base64');
5282
+ qrUnicode = uni;
5283
+ } catch (qrErr: unknown) {
5284
+ api.logger.warn(
5285
+ `totalreclaw_pair: QR encode failed (non-fatal): ${
5286
+ qrErr instanceof Error ? qrErr.message : String(qrErr)
5287
+ }`,
5288
+ );
5289
+ }
5290
+
5239
5291
  api.logger.info(
5240
- `totalreclaw_pair: session ${session.sid.slice(0, 8)}… mode=${mode} url=${url}`,
5292
+ `totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`,
5241
5293
  );
5294
+ // Voidly reference localSession so TS does not flag the unused
5295
+ // local-branch binding. Future rc.12 diagnostics can expose
5296
+ // `session.mode` / `session.status` separately.
5297
+ void localSession;
5242
5298
  return {
5243
5299
  content: [{
5244
5300
  type: 'text',
5245
5301
  text:
5246
5302
  `Pairing session started.\n\n` +
5247
5303
  `URL: ${url}\n\n` +
5248
- `PIN (type this into the browser): ${session.secondaryCode}\n\n` +
5304
+ `PIN (type this into the browser): ${pin}\n\n` +
5249
5305
  (qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
5250
5306
  `Instructions for the user:\n` +
5251
5307
  `1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
@@ -5259,12 +5315,18 @@ const plugin = {
5259
5315
  `This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
5260
5316
  }],
5261
5317
  details: {
5262
- sid: session.sid,
5318
+ sid: sidOrToken,
5263
5319
  url,
5264
- pin: session.secondaryCode,
5320
+ pin,
5265
5321
  mode,
5266
- expires_at_ms: session.expiresAtMs,
5322
+ expires_at_ms: expiresAtMs,
5267
5323
  qr_ascii: qrAscii,
5324
+ qr_png_b64: qrPngB64,
5325
+ qr_unicode: qrUnicode,
5326
+ // rc.11 — surface the transport so downstream tooling (QA
5327
+ // harness asserters, telemetry) can confirm which path
5328
+ // served the URL. Either 'relay' or 'local'.
5329
+ transport: pairMode,
5268
5330
  },
5269
5331
  };
5270
5332
  } catch (err: unknown) {
@@ -5279,6 +5341,166 @@ const plugin = {
5279
5341
  },
5280
5342
  { name: 'totalreclaw_pair' },
5281
5343
  );
5344
+ // 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
5345
+ // ops/QA can grep gateway logs for definitive proof the tool was
5346
+ // declared. If the agent then reports the tool is missing from its
5347
+ // tool list, the gap is upstream OpenClaw tool propagation, not our
5348
+ // plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
5349
+ //
5350
+ // 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
5351
+ // bleeding into `openclaw agent --json` stdout, breaking programmatic
5352
+ // parsers that expect the JSON-RPC body to be the only thing on the
5353
+ // wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
5354
+ // `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
5355
+ // a tool-injection regression. Default OFF — clean stdout for users.
5356
+ if (CONFIG.verboseRegister) {
5357
+ api.logger.info(
5358
+ 'TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
5359
+ 'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
5360
+ '`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.',
5361
+ );
5362
+ }
5363
+
5364
+ // ---------------------------------------------------------------
5365
+ // Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
5366
+ //
5367
+ // Lets the agent file a structured QA-bug issue to
5368
+ // `p-diogo/totalreclaw-internal` during RC testing. Only registered
5369
+ // when the plugin version contains `-rc.` — stable users never see it.
5370
+ //
5371
+ // Secrets (recovery phrases, API keys, Telegram bot tokens) are
5372
+ // redacted inside `postQaBugIssue` before the POST. The agent should
5373
+ // still avoid passing raw secrets — see SKILL.md addendum.
5374
+ // ---------------------------------------------------------------
5375
+ if (rcMode) {
5376
+ api.registerTool(
5377
+ {
5378
+ name: 'totalreclaw_report_qa_bug',
5379
+ label: 'File a QA bug issue (RC builds only)',
5380
+ description:
5381
+ 'File a structured QA bug report to the internal tracker. RC-only; never available in stable builds. ' +
5382
+ 'Do NOT call auto-file — ask the user first before invoking. The tool redacts recovery phrases, API keys, ' +
5383
+ 'and Telegram bot tokens from all free-text fields before posting, but the agent SHOULD still avoid ' +
5384
+ 'passing raw secrets.',
5385
+ parameters: {
5386
+ type: 'object',
5387
+ properties: {
5388
+ integration: {
5389
+ type: 'string',
5390
+ enum: ['plugin', 'hermes', 'nanoclaw', 'mcp', 'relay', 'clawhub', 'docs', 'other'],
5391
+ description: 'Which TotalReclaw surface is affected.',
5392
+ },
5393
+ rc_version: {
5394
+ type: 'string',
5395
+ description: 'Exact RC version string (e.g. "3.3.1-rc.3" or "2.3.1rc3").',
5396
+ },
5397
+ severity: {
5398
+ type: 'string',
5399
+ enum: ['blocker', 'high', 'medium', 'low'],
5400
+ description: 'blocker=release blocked, high=major UX failure, medium=annoying, low=polish.',
5401
+ },
5402
+ title: {
5403
+ type: 'string',
5404
+ description: 'Short summary, <60 chars. Prefix "[qa-bug]" is added automatically.',
5405
+ maxLength: 60,
5406
+ },
5407
+ symptom: {
5408
+ type: 'string',
5409
+ description: 'What happened (redacted automatically).',
5410
+ },
5411
+ expected: {
5412
+ type: 'string',
5413
+ description: 'What should have happened.',
5414
+ },
5415
+ repro: {
5416
+ type: 'string',
5417
+ description: 'Reproduction steps (redacted automatically).',
5418
+ },
5419
+ logs: {
5420
+ type: 'string',
5421
+ description: 'Log excerpts / error messages (redacted automatically).',
5422
+ },
5423
+ environment: {
5424
+ type: 'string',
5425
+ description: 'Host, Docker/native, OpenClaw version, LLM provider, etc.',
5426
+ },
5427
+ },
5428
+ required: [
5429
+ 'integration',
5430
+ 'rc_version',
5431
+ 'severity',
5432
+ 'title',
5433
+ 'symptom',
5434
+ 'expected',
5435
+ 'repro',
5436
+ 'logs',
5437
+ 'environment',
5438
+ ],
5439
+ additionalProperties: false,
5440
+ },
5441
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
5442
+ try {
5443
+ const { postQaBugIssue } = await import('./qa-bug-report.js');
5444
+ // The token is resolved via CONFIG (config.ts) so index.ts
5445
+ // stays clean of env-harvesting triggers.
5446
+ const token = CONFIG.qaGithubToken;
5447
+ if (!token) {
5448
+ return {
5449
+ content: [{
5450
+ type: 'text',
5451
+ text:
5452
+ 'Cannot file QA bug: no GitHub token found. The operator must export ' +
5453
+ 'TOTALRECLAW_QA_GITHUB_TOKEN (or GITHUB_TOKEN) with `repo` scope to enable ' +
5454
+ 'agent-filed bug reports during RC testing.',
5455
+ }],
5456
+ details: { error: 'missing_github_token' },
5457
+ };
5458
+ }
5459
+ // rc.14 — `repo` is resolved inside `postQaBugIssue` via
5460
+ // `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
5461
+ // refuses any slug that isn't a `-internal` fork. Pass the
5462
+ // config-surfaced override so env reads stay in config.ts.
5463
+ const repoOverride = CONFIG.qaRepoOverride || undefined;
5464
+ const result = await postQaBugIssue(
5465
+ params as unknown as import('./qa-bug-report.js').QaBugArgs,
5466
+ {
5467
+ githubToken: token,
5468
+ repo: repoOverride,
5469
+ logger: api.logger,
5470
+ },
5471
+ );
5472
+ return {
5473
+ content: [{
5474
+ type: 'text',
5475
+ text: `Filed QA bug #${result.issue_number}: ${result.issue_url}`,
5476
+ }],
5477
+ details: { issue_url: result.issue_url, issue_number: result.issue_number },
5478
+ };
5479
+ } catch (err: unknown) {
5480
+ const message = err instanceof Error ? err.message : String(err);
5481
+ api.logger.error(`totalreclaw_report_qa_bug failed: ${message}`);
5482
+ return {
5483
+ content: [{
5484
+ type: 'text',
5485
+ text: `Failed to file QA bug: ${message}`,
5486
+ }],
5487
+ details: { error: message },
5488
+ };
5489
+ }
5490
+ },
5491
+ },
5492
+ { name: 'totalreclaw_report_qa_bug' },
5493
+ );
5494
+ // 3.3.1-rc.21 (issue #128): demote the registration-confirmation
5495
+ // breadcrumb to verbose-only. Same `--json` stdout pollution risk
5496
+ // as the totalreclaw_pair breadcrumb above; ops can opt back in
5497
+ // via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
5498
+ if (CONFIG.verboseRegister) {
5499
+ api.logger.info(
5500
+ 'totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).',
5501
+ );
5502
+ }
5503
+ }
5282
5504
 
5283
5505
  // ---------------------------------------------------------------
5284
5506
  // Hook: before_tool_call (3.2.0 memory-tool gate)
@@ -5372,11 +5594,18 @@ const plugin = {
5372
5594
  '## TotalReclaw setup pending\n\n' +
5373
5595
  'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
5374
5596
  'If the user asks about memory features or wants to configure TotalReclaw, ' +
5375
- 'call the `totalreclaw_onboarding_start` tool for instructions.\n\n' +
5597
+ 'call the `totalreclaw_pair` tool it returns a pairing URL + 6-digit PIN ' +
5598
+ 'the user opens in a browser (phrase is generated and encrypted in-browser, ' +
5599
+ 'never leaves their device in plaintext). If `totalreclaw_pair` is not in ' +
5600
+ 'your tool list (container-based agents sometimes miss it), shell out to ' +
5601
+ '`openclaw totalreclaw pair generate --url-pin-only` on the gateway host — ' +
5602
+ 'it emits a single JSON line `{v,url,pin,expires_at_ms}` with no phrase ' +
5603
+ 'material; hand the user the `url` and `pin`.\n\n' +
5376
5604
  '**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
5377
- 'All phrase handling happens on the user\'s local terminal via ' +
5378
- '`openclaw totalreclaw onboard` this keeps the phrase out of the LLM ' +
5379
- 'provider\'s logs.',
5605
+ '**Do NOT** run `openclaw totalreclaw onboard` that CLI emits the recovery ' +
5606
+ 'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
5607
+ '(tool or `--url-pin-only` CLI) instead; `onboard` is reserved for users ' +
5608
+ 'running it directly in their own local terminal.',
5380
5609
  };
5381
5610
  }
5382
5611
 
@@ -5406,7 +5635,7 @@ const plugin = {
5406
5635
  const walletParam = encodeURIComponent(subgraphOwner || userId || '');
5407
5636
  const billingResp = await fetch(`${billingUrl}/v1/billing/status?wallet_address=${walletParam}`, {
5408
5637
  method: 'GET',
5409
- headers: { 'Authorization': `Bearer ${authKeyHex}`, 'Accept': 'application/json', 'X-TotalReclaw-Client': 'openclaw-plugin' },
5638
+ headers: buildRelayHeaders({ 'Authorization': `Bearer ${authKeyHex}`, 'Accept': 'application/json' }),
5410
5639
  });
5411
5640
  if (billingResp.ok) {
5412
5641
  const billingData = await billingResp.json() as Record<string, unknown>;
@@ -5643,14 +5872,14 @@ const plugin = {
5643
5872
 
5644
5873
  if (reranked.length === 0) return undefined;
5645
5874
 
5646
- // 6b. Cosine similarity threshold gate — skip injection when the
5647
- // best match is below the minimum relevance threshold.
5648
- const hookMaxCosine = Math.max(
5649
- ...reranked.map((r) => r.cosineSimilarity ?? 0),
5650
- );
5651
- if (hookMaxCosine < COSINE_THRESHOLD) {
5875
+ // 6b. Relevance gate — see recall tool above for the cosine +
5876
+ // lexical-override rule (issue #116).
5877
+ if (!passesRelevanceGate(evt.prompt, reranked, COSINE_THRESHOLD)) {
5878
+ const hookMaxCosine = Math.max(
5879
+ ...reranked.map((r) => r.cosineSimilarity ?? 0),
5880
+ );
5652
5881
  api.logger.info(
5653
- `Hook: cosine threshold gate filtered results (max=${hookMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
5882
+ `Hook: relevance gate filtered results (max cosine=${hookMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD}, no lexical override)`,
5654
5883
  );
5655
5884
  return undefined;
5656
5885
  }
@@ -5758,14 +5987,14 @@ const plugin = {
5758
5987
 
5759
5988
  if (reranked.length === 0) return undefined;
5760
5989
 
5761
- // Cosine similarity threshold gate — skip injection when the
5762
- // best match is below the minimum relevance threshold.
5763
- const srvMaxCosine = Math.max(
5764
- ...reranked.map((r) => r.cosineSimilarity ?? 0),
5765
- );
5766
- if (srvMaxCosine < COSINE_THRESHOLD) {
5990
+ // Relevance gate — see recall tool for the cosine + lexical-override
5991
+ // rule (issue #116).
5992
+ if (!passesRelevanceGate(evt.prompt, reranked, COSINE_THRESHOLD)) {
5993
+ const srvMaxCosine = Math.max(
5994
+ ...reranked.map((r) => r.cosineSimilarity ?? 0),
5995
+ );
5767
5996
  api.logger.info(
5768
- `Hook: cosine threshold gate filtered results (max=${srvMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
5997
+ `Hook: relevance gate filtered results (max cosine=${srvMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD}, no lexical override)`,
5769
5998
  );
5770
5999
  return undefined;
5771
6000
  }