@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/index.ts CHANGED
@@ -73,7 +73,15 @@ import {
73
73
  type MemorySource,
74
74
  type MemoryScope,
75
75
  } from './extractor.js';
76
- import { initLLMClient, resolveLLMConfig, chatCompletion, generateEmbedding, getEmbeddingDims } from './llm-client.js';
76
+ import {
77
+ initLLMClient,
78
+ resolveLLMConfig,
79
+ chatCompletion,
80
+ generateEmbedding,
81
+ getEmbeddingDims,
82
+ getEmbeddingModelId,
83
+ configureEmbedder,
84
+ } from './llm-client.js';
77
85
  import {
78
86
  defaultAuthProfilesRoot,
79
87
  readAllProfileKeys,
@@ -92,6 +100,7 @@ import {
92
100
  type DecryptedCandidate,
93
101
  } from './consolidation.js';
94
102
  import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactOnChain, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4, type FactPayload } from './subgraph-store.js';
103
+ import { confirmIndexed } from './confirm-indexed.js';
95
104
  import {
96
105
  DIGEST_TRAPDOOR,
97
106
  buildCanonicalClaim,
@@ -135,6 +144,7 @@ import {
135
144
  } from './onboarding-cli.js';
136
145
  import { PluginHotCache, type HotFact } from './hot-cache-wrapper.js';
137
146
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
147
+ import { buildRelayHeaders } from './relay-headers.js';
138
148
  import {
139
149
  readBillingCache,
140
150
  writeBillingCache,
@@ -151,6 +161,9 @@ import {
151
161
  resolveOnboardingState,
152
162
  writeOnboardingState,
153
163
  readPluginVersion,
164
+ cleanupInstallStagingDirs,
165
+ detectPartialInstall,
166
+ clearPartialInstallMarker,
154
167
  type OnboardingState,
155
168
  } from './fs-helpers.js';
156
169
  import { isRcBuild } from './qa-bug-report.js';
@@ -161,6 +174,19 @@ import { detectGatewayHost } from './gateway-url.js';
161
174
  import { validateMnemonic } from '@scure/bip39';
162
175
  import { wordlist } from '@scure/bip39/wordlists/english.js';
163
176
  import crypto from 'node:crypto';
177
+ import { createRequire } from 'node:module';
178
+ import { fileURLToPath } from 'node:url';
179
+ import * as nodePath from 'node:path';
180
+
181
+ // CJS-style require for the @totalreclaw/core WASM module. We keep this
182
+ // load path lazy (only inside getSmartImportWasm() below) so a partial
183
+ // install of the dependency tree doesn't crash module init. Bare
184
+ // `require()` is a CommonJS global and is undefined under bare Node ESM —
185
+ // the shipped `dist/index.js` declares `"type":"module"`, so calling the
186
+ // global directly emits "require is not defined" at runtime (issue #124).
187
+ // createRequire bridges the gap. Same shape as crypto.ts / lsh.ts /
188
+ // subgraph-store.ts / claims-helper.ts.
189
+ const __cjsRequire = createRequire(import.meta.url);
164
190
 
165
191
  // ---------------------------------------------------------------------------
166
192
  // OpenClaw Plugin API type (defined locally to avoid SDK dependency)
@@ -341,8 +367,17 @@ function buildPairingUrl(
341
367
  // Layers 4 + 5 — auto-detect via gateway-url helper (Tailscale CGNAT, then LAN)
342
368
  else {
343
369
  let detected: ReturnType<typeof detectGatewayHost> = null;
370
+ // issue #110 fix 4 — pass `isDocker` so LAN detection skips
371
+ // 172.16/12 bridge IPs that no external browser can reach.
372
+ let isDocker = false;
344
373
  try {
345
- detected = detectGatewayHost();
374
+ isDocker = isRunningInDocker();
375
+ } catch {
376
+ // Defensive: never block URL building on Docker sniff errors.
377
+ isDocker = false;
378
+ }
379
+ try {
380
+ detected = detectGatewayHost({ isDocker });
346
381
  } catch (err) {
347
382
  api.logger.warn(
348
383
  `TotalReclaw: host autodetect crashed: ${err instanceof Error ? err.message : String(err)} — falling back to localhost`,
@@ -368,9 +403,27 @@ function buildPairingUrl(
368
403
  `Set plugins.entries.totalreclaw.config.publicUrl for remote access.`,
369
404
  );
370
405
  } else {
371
- // Layer 6 — localhost fallback
406
+ // Layer 6 — localhost fallback (or Docker-aware relay-pointer warning)
372
407
  const bind = cfg?.gateway?.bind;
373
- if (bind === 'lan' || bind === 'tailnet') {
408
+ if (isDocker) {
409
+ // issue #110 fix 4: inside Docker the LAN IP is container-internal
410
+ // and useless. Loopback localhost only works for `docker exec`
411
+ // tests. The CORRECT pair URL for Docker is the relay-brokered
412
+ // path served by the `totalreclaw_pair` agent tool (CONFIG.pairMode
413
+ // === 'relay' since rc.11). The CLI-only path here cannot mint a
414
+ // relay session synchronously (the relay handshake needs a WS
415
+ // round-trip), so we emit the loopback URL with a LOUD warning
416
+ // pointing the operator at the agent tool / publicUrl override.
417
+ api.logger.warn(
418
+ `TotalReclaw: Docker container detected — pairing URL falling back to ` +
419
+ `http://localhost:${port}, which is unreachable from the host browser. ` +
420
+ `Use the totalreclaw_pair AGENT TOOL (relay-brokered, universally reachable) ` +
421
+ `instead of the CLI fallback, OR set plugins.entries.totalreclaw.config.publicUrl ` +
422
+ `to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
423
+ `Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
424
+ `air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`,
425
+ );
426
+ } else if (bind === 'lan' || bind === 'tailnet') {
374
427
  api.logger.warn(
375
428
  `TotalReclaw: pairing URL falling back to localhost because gateway.bind=${bind} could not be autodetected. ` +
376
429
  'Set plugins.entries.totalreclaw.config.publicUrl to override.',
@@ -849,11 +902,10 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
849
902
  const billingUrl = CONFIG.serverUrl;
850
903
  const resp = await fetch(`${billingUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
851
904
  method: 'GET',
852
- headers: {
905
+ headers: buildRelayHeaders({
853
906
  'Authorization': `Bearer ${authKeyHex}`,
854
907
  'Accept': 'application/json',
855
- 'X-TotalReclaw-Client': 'openclaw-plugin',
856
- },
908
+ }),
857
909
  });
858
910
  if (resp.ok) {
859
911
  const billingData = await resp.json() as Record<string, unknown>;
@@ -1426,11 +1478,11 @@ async function migrationGqlQuery<T>(
1426
1478
  authKey?: string,
1427
1479
  ): Promise<T | null> {
1428
1480
  try {
1429
- const headers: Record<string, string> = {
1481
+ const overrides: Record<string, string> = {
1430
1482
  'Content-Type': 'application/json',
1431
- 'X-TotalReclaw-Client': 'openclaw-plugin',
1432
1483
  };
1433
- if (authKey) headers['Authorization'] = `Bearer ${authKey}`;
1484
+ if (authKey) overrides['Authorization'] = `Bearer ${authKey}`;
1485
+ const headers = buildRelayHeaders(overrides);
1434
1486
  const response = await fetch(endpoint, {
1435
1487
  method: 'POST',
1436
1488
  headers,
@@ -1905,6 +1957,11 @@ async function storeExtractedFacts(
1905
1957
  fact: factForBlob,
1906
1958
  importance: effectiveImportance,
1907
1959
  sourceAgent: factSource,
1960
+ // 3.3.1-rc.22 — tag every new claim with the active embedder id
1961
+ // so future distillation can rescore selectively. Plugin-only
1962
+ // field; survives the core validator strip via re-attach in
1963
+ // `buildCanonicalClaimV1`.
1964
+ embeddingModelId: getEmbeddingModelId(),
1908
1965
  });
1909
1966
 
1910
1967
  const factId = crypto.randomUUID();
@@ -2271,10 +2328,13 @@ async function handlePluginImportFrom(
2271
2328
  // Smart Import — Two-Pass Pipeline (Profile + Triage)
2272
2329
  // ---------------------------------------------------------------------------
2273
2330
 
2274
- // Lazy-load WASM for smart import functions (same pattern as crypto.ts / subgraph-store.ts).
2331
+ // Lazy-load WASM for smart import functions (same pattern as crypto.ts /
2332
+ // subgraph-store.ts). Goes through __cjsRequire (createRequire(import.meta.url))
2333
+ // declared at the top of the file — bare `require()` is undefined under
2334
+ // pure-ESM Node, see issue #124.
2275
2335
  let _smartImportWasm: typeof import('@totalreclaw/core') | null = null;
2276
2336
  function getSmartImportWasm() {
2277
- if (!_smartImportWasm) _smartImportWasm = require('@totalreclaw/core');
2337
+ if (!_smartImportWasm) _smartImportWasm = __cjsRequire('@totalreclaw/core');
2278
2338
  return _smartImportWasm;
2279
2339
  }
2280
2340
 
@@ -2805,17 +2865,87 @@ const plugin = {
2805
2865
  // function — stable builds never see it. The version is resolved via
2806
2866
  // `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
2807
2867
  let rcMode = false;
2868
+ // Plugin version resolved from package.json once at register time. Reused
2869
+ // by writeOnboardingState callsites below so the `version` field in
2870
+ // state.json tracks the actual shipped plugin version (avoids drift —
2871
+ // e.g. rc.18 finding F4 where a hardcoded `'3.3.1-rc.11'` stayed put
2872
+ // through 7 RC bumps). Fallback `'3.3.0'` matches the prior literal at
2873
+ // the loopback callsite if package.json read fails.
2874
+ let pluginVersion: string | null = null;
2808
2875
  try {
2809
- // `import.meta.url` is ESM-only; fallback to `__dirname` for the CJS
2810
- // build path. `require` comes from Node core and is available in both
2811
- // module formats. `fileURLToPath` / `path.dirname` are pure-sync.
2812
- const url = require('node:url') as typeof import('node:url');
2813
- const nodePath = require('node:path') as typeof import('node:path');
2814
- const pluginDir = nodePath.dirname(url.fileURLToPath(import.meta.url));
2815
- const version = readPluginVersion(pluginDir);
2816
- rcMode = isRcBuild(version);
2876
+ // Resolve our own dist/ directory so `readPluginVersion` can locate
2877
+ // package.json. We use `import.meta.url` + ESM-static stdlib imports
2878
+ // (`fileURLToPath` from `node:url`, `nodePath.dirname` from `node:path`,
2879
+ // both imported at the top of this file). Earlier shape used inline
2880
+ // `require('node:url')` undefined under bare-ESM Node, broke the
2881
+ // before_agent_start hook in the published rc.20 bundle (issue #124).
2882
+ const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
2883
+ pluginVersion = readPluginVersion(pluginDir);
2884
+ rcMode = isRcBuild(pluginVersion);
2817
2885
  if (rcMode) {
2818
- api.logger.info(`TotalReclaw: RC build detected (version=${version}). RC-gated tools will be registered.`);
2886
+ api.logger.info(`TotalReclaw: RC build detected (version=${pluginVersion}). RC-gated tools will be registered.`);
2887
+ }
2888
+
2889
+ // 3.3.1-rc.21 (issue #126 — rc.20 finding F3): clean up
2890
+ // `.openclaw-install-stage-*` siblings left behind by an interrupted
2891
+ // `openclaw plugins install` run. Without cleanup, OpenClaw's plugin
2892
+ // loader auto-discovers the orphan directory on the next gateway
2893
+ // start and registers a duplicate `totalreclaw` plugin (duplicate
2894
+ // hooks, duplicate tools, "duplicate-plugin-id" warning every cycle).
2895
+ // Best-effort — never throws on permission / race failures.
2896
+ try {
2897
+ const removed = cleanupInstallStagingDirs(pluginDir);
2898
+ if (removed.length > 0) {
2899
+ api.logger.info(
2900
+ `TotalReclaw: removed ${removed.length} stale install-staging dir(s) from prior interrupted install: ${removed.join(', ')}`,
2901
+ );
2902
+ }
2903
+ } catch {
2904
+ // Best-effort — already swallowed inside the helper, but keep this
2905
+ // outer try as belt-and-braces against future helper changes.
2906
+ }
2907
+
2908
+ // 3.3.1-rc.22 — wire the lazy-embedder runtime config so the first
2909
+ // `generateEmbedding()` call knows where to cache the bundle and
2910
+ // which RC's GitHub Release to fetch from. `pluginVersion` may be
2911
+ // `null` if package.json is unreadable; the embedder defaults to
2912
+ // a "0.0.0-dev" tag in that case.
2913
+ try {
2914
+ configureEmbedder({
2915
+ cacheRoot: CONFIG.embedderCachePath,
2916
+ rcTag: pluginVersion ?? '0.0.0-dev',
2917
+ });
2918
+ } catch (err) {
2919
+ const msg = err instanceof Error ? err.message : String(err);
2920
+ api.logger.warn(`TotalReclaw: configureEmbedder failed (will use defaults): ${msg}`);
2921
+ }
2922
+
2923
+ // 3.3.1-rc.22 (rc.21 finding #5): self-heal partial-install marker.
2924
+ // The `preinstall` npm script writes `.tr-partial-install`; the
2925
+ // `postinstall` script removes it on a successful install. If we
2926
+ // have gotten this far the loader did register us — meaning the
2927
+ // install succeeded enough to be useful — so any lingering marker
2928
+ // (e.g. npm ran preinstall but postinstall misfired) is stale.
2929
+ // Clear it so the next retry's detector does not see a false positive.
2930
+ //
2931
+ // 3.3.1-rc.22 (rc.21 finding #6) — gateway/reload upstream caveat:
2932
+ // OpenClaw's config-watcher fires `gateway/reload` when
2933
+ // `plugins.entries.totalreclaw` mutates (e.g. mid-install). In-flight
2934
+ // CLI clients see `1006 abnormal closure` and start a 600-second wait.
2935
+ // Proper fix is upstream OpenClaw FR. Plugin-side mitigation = these
2936
+ // helper calls MUST be idempotent under repeated register() calls
2937
+ // triggered by reload chatter. Asserted by
2938
+ // `install-reload-idempotency.test.ts`.
2939
+ try {
2940
+ const pluginRoot = nodePath.resolve(pluginDir, '..');
2941
+ const cleared = clearPartialInstallMarker(pluginRoot);
2942
+ if (cleared) {
2943
+ api.logger.info(
2944
+ `TotalReclaw: cleared stale .tr-partial-install marker (rc.22 finding #5)`,
2945
+ );
2946
+ }
2947
+ } catch {
2948
+ // Best-effort. Helper logs internally and never throws.
2819
2949
  }
2820
2950
  } catch {
2821
2951
  rcMode = false;
@@ -2902,6 +3032,12 @@ const plugin = {
2902
3032
  credentialsPath: CREDENTIALS_PATH,
2903
3033
  statePath: CONFIG.onboardingStatePath,
2904
3034
  logger: api.logger,
3035
+ // 3.3.1-rc.18 — wire the pair flow into onboard so the
3036
+ // `--pair-only` flag (issue #95) can delegate to it without
3037
+ // duplicating session-store / URL-builder logic. Same deps
3038
+ // as the standalone `pair` subcommand.
3039
+ pairSessionsPath: CONFIG.pairSessionsPath,
3040
+ renderPairingUrl: (session) => buildPairingUrl(api, session),
2905
3041
  // 3.3.1 — supplied to the non-interactive --json onboard path
2906
3042
  // so the emitted payload includes the derived Smart Account
2907
3043
  // (scope) address. Uses the chain-id default; Pro-tier
@@ -2974,8 +3110,30 @@ const plugin = {
2974
3110
  // Write credentials.json + flip state to 'active' via
2975
3111
  // fs-helpers. This centralizes disk I/O off the
2976
3112
  // pair-http surface (scanner isolation).
3113
+ //
3114
+ // 3.3.1 (internal#130) — derive + persist the Smart Account
3115
+ // address right here so the user can see their scope address
3116
+ // immediately after pair, without waiting for a first chain
3117
+ // write. SA derivation runs locally (WASM deriveEoa + factory
3118
+ // getAddress eth_call); the mnemonic NEVER crosses any new
3119
+ // boundary — it's already on disk in credentials.json and is
3120
+ // consumed by the same `deriveSmartAccountAddress` call the
3121
+ // store/search paths use. Only the derived public address is
3122
+ // persisted to credentials.json (`scope_address`).
3123
+ let scopeAddress: string | undefined;
3124
+ try {
3125
+ scopeAddress = await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
3126
+ } catch (err) {
3127
+ // Best-effort. If chain RPC is unreachable at pair time, the
3128
+ // status tool re-tries derivation lazily on next call —
3129
+ // fall through and write credentials.json without it.
3130
+ api.logger.warn(
3131
+ `pair: scope_address derivation failed (will retry lazily): ${err instanceof Error ? err.message : String(err)}`,
3132
+ );
3133
+ }
2977
3134
  const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
2978
- const next = { ...creds, mnemonic };
3135
+ const next: typeof creds = { ...creds, mnemonic };
3136
+ if (scopeAddress) next.scope_address = scopeAddress;
2979
3137
  if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
2980
3138
  return { state: 'error', error: 'credentials_write_failed' };
2981
3139
  }
@@ -2988,7 +3146,7 @@ const plugin = {
2988
3146
  onboardingState: 'active',
2989
3147
  createdBy: 'generate',
2990
3148
  credentialsCreatedAt: new Date().toISOString(),
2991
- version: '3.3.0',
3149
+ version: pluginVersion ?? '3.3.0',
2992
3150
  });
2993
3151
  return { state: 'active' };
2994
3152
  },
@@ -3523,20 +3681,10 @@ const plugin = {
3523
3681
  };
3524
3682
  }
3525
3683
 
3526
- // 6b. Cosine similarity threshold gate skip results when the
3527
- // best match is below the minimum relevance threshold.
3528
- const maxCosine = Math.max(
3529
- ...reranked.map((r) => r.cosineSimilarity ?? 0),
3530
- );
3531
- if (maxCosine < COSINE_THRESHOLD) {
3532
- api.logger.info(
3533
- `Recall: cosine threshold gate filtered results (max=${maxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
3534
- );
3535
- return {
3536
- content: [{ type: 'text', text: 'No relevant memories found for this query.' }],
3537
- details: { count: 0, memories: [] },
3538
- };
3539
- }
3684
+ // 6b. Relevance gate removed in rc.22 -- core's intent-weighted
3685
+ // RRF + Tier 1 source weighting handles short queries via the
3686
+ // BM25 component, making the rc.18 cosine + lexical-override
3687
+ // band-aid (issue #116) redundant.
3540
3688
 
3541
3689
  // 7. Format results.
3542
3690
  const lines = reranked.map((m, i) => {
@@ -3675,14 +3823,29 @@ const plugin = {
3675
3823
  throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
3676
3824
  }
3677
3825
  api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
3826
+ // Read-after-write: poll the subgraph until the original fact id
3827
+ // is no longer active (forget flips isActive=false). On timeout
3828
+ // surface `partial: true` so the agent can explain the chain
3829
+ // write succeeded but the subgraph is still propagating.
3830
+ const confirm = await confirmIndexed(factId, {
3831
+ expect: 'inactive',
3832
+ authKeyHex: authKeyHex!,
3833
+ });
3678
3834
  return {
3679
3835
  content: [{
3680
3836
  type: 'text',
3681
- text:
3682
- `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
3683
- 'The subgraph will reflect isActive=false within ~30 seconds.',
3837
+ text: confirm.indexed
3838
+ ? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
3839
+ : `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
3840
+ 'The subgraph indexer is still propagating the change — ' +
3841
+ 'recall/export may briefly show the memory as still active.',
3684
3842
  }],
3685
- details: { deleted: true, txHash: result.txHash, factId },
3843
+ details: {
3844
+ deleted: true,
3845
+ txHash: result.txHash,
3846
+ factId,
3847
+ ...(confirm.indexed ? {} : { partial: true }),
3848
+ },
3686
3849
  };
3687
3850
  } else {
3688
3851
  await apiClient!.deleteFact(factId, authKeyHex!);
@@ -3758,11 +3921,10 @@ const plugin = {
3758
3921
 
3759
3922
  const res = await fetch(`${relayUrl}/v1/subgraph`, {
3760
3923
  method: 'POST',
3761
- headers: {
3924
+ headers: buildRelayHeaders({
3762
3925
  'Content-Type': 'application/json',
3763
- 'X-TotalReclaw-Client': 'openclaw-plugin',
3764
3926
  ...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
3765
- },
3927
+ }),
3766
3928
  body: JSON.stringify({ query, variables }),
3767
3929
  });
3768
3930
 
@@ -3898,11 +4060,10 @@ const plugin = {
3898
4060
  const walletAddr = subgraphOwner || userId || '';
3899
4061
  const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
3900
4062
  method: 'GET',
3901
- headers: {
4063
+ headers: buildRelayHeaders({
3902
4064
  'Authorization': `Bearer ${authKeyHex}`,
3903
4065
  'Accept': 'application/json',
3904
- 'X-TotalReclaw-Client': 'openclaw-plugin',
3905
- },
4066
+ }),
3906
4067
  });
3907
4068
 
3908
4069
  if (!response.ok) {
@@ -3927,6 +4088,37 @@ const plugin = {
3927
4088
  checked_at: Date.now(),
3928
4089
  });
3929
4090
 
4091
+ // 3.3.1 (internal#130) — surface the Smart Account / scope
4092
+ // address so the user can do subgraph queries, BaseScan
4093
+ // lookups, and cross-client portability checks BEFORE any
4094
+ // chain write completes. Resolution priority:
4095
+ // 1. In-memory `subgraphOwner` (already derived earlier).
4096
+ // 2. credentials.json `scope_address` (persisted at pair).
4097
+ // 3. Lazy derive now from the loaded mnemonic + cache it
4098
+ // back to credentials.json so the next call is free.
4099
+ let scopeAddress: string | undefined =
4100
+ subgraphOwner ?? undefined;
4101
+ if (!scopeAddress) {
4102
+ try {
4103
+ const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
4104
+ if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
4105
+ scopeAddress = credsCache.scope_address;
4106
+ } else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
4107
+ scopeAddress = await deriveSmartAccountAddress(
4108
+ credsCache.mnemonic,
4109
+ CONFIG.chainId,
4110
+ );
4111
+ if (scopeAddress) {
4112
+ writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
4113
+ }
4114
+ }
4115
+ } catch (deriveErr) {
4116
+ api.logger.warn(
4117
+ `totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
4118
+ );
4119
+ }
4120
+ }
4121
+
3930
4122
  const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
3931
4123
  const lines: string[] = [
3932
4124
  `Tier: ${tierLabel}`,
@@ -3935,13 +4127,21 @@ const plugin = {
3935
4127
  if (freeWritesResetAt) {
3936
4128
  lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
3937
4129
  }
4130
+ if (scopeAddress) {
4131
+ lines.push(`Smart Account: ${scopeAddress}`);
4132
+ }
3938
4133
  if (tier !== 'pro') {
3939
4134
  lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
3940
4135
  }
3941
4136
 
3942
4137
  return {
3943
4138
  content: [{ type: 'text', text: lines.join('\n') }],
3944
- details: { tier, free_writes_used: freeWritesUsed, free_writes_limit: freeWritesLimit },
4139
+ details: {
4140
+ tier,
4141
+ free_writes_used: freeWritesUsed,
4142
+ free_writes_limit: freeWritesLimit,
4143
+ scope_address: scopeAddress,
4144
+ },
3945
4145
  };
3946
4146
  } catch (err: unknown) {
3947
4147
  const message = err instanceof Error ? err.message : String(err);
@@ -4664,11 +4864,10 @@ const plugin = {
4664
4864
 
4665
4865
  const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
4666
4866
  method: 'POST',
4667
- headers: {
4867
+ headers: buildRelayHeaders({
4668
4868
  'Authorization': `Bearer ${authKeyHex}`,
4669
4869
  'Content-Type': 'application/json',
4670
- 'X-TotalReclaw-Client': 'openclaw-plugin',
4671
- },
4870
+ }),
4672
4871
  body: JSON.stringify({
4673
4872
  wallet_address: walletAddr,
4674
4873
  tier: 'pro',
@@ -4752,11 +4951,10 @@ const plugin = {
4752
4951
  `${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`,
4753
4952
  {
4754
4953
  method: 'GET',
4755
- headers: {
4954
+ headers: buildRelayHeaders({
4756
4955
  'Authorization': `Bearer ${authKeyHex}`,
4757
4956
  'Content-Type': 'application/json',
4758
- 'X-TotalReclaw-Client': 'openclaw-plugin',
4759
- },
4957
+ }),
4760
4958
  },
4761
4959
  );
4762
4960
  if (!billingResp.ok) {
@@ -4891,162 +5089,36 @@ const plugin = {
4891
5089
  );
4892
5090
 
4893
5091
  // ---------------------------------------------------------------
4894
- // Tool: totalreclaw_setup (DEPRECATED in 3.2.0)
5092
+ // Tools: totalreclaw_setup + totalreclaw_onboarding_start
5093
+ // REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
4895
5094
  // ---------------------------------------------------------------
4896
5095
  //
4897
- // Pre-3.2.0 behaviour: auto-generate a mnemonic + return it in the tool
4898
- // response body so the LLM surfaces it to the user. That path shipped
4899
- // the recovery phrase to the LLM provider's logs — incompatible with
4900
- // TotalReclaw's "server cannot read your memories" pitch. 3.2.0
4901
- // replaces it with a pointer-only stub.
5096
+ // rc.4 left these two registrations in place as *neutered* stubs
5097
+ // ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
5098
+ // and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
5099
+ // was already pointer-only. Neither path could leak a phrase in
5100
+ // rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
5101
+ // regression surface: any future patch that re-enables phrase
5102
+ // acceptance (e.g. a flag-driven "power-user" path) would silently
5103
+ // re-open the leak, and their mere presence in the tool registry
5104
+ // keeps signalling to agents that "phrase handling happens here".
4902
5105
  //
4903
- // Kept registered (rather than deleted) for back-compat — LLMs that
4904
- // learned the old tool name from training data won't silently succeed
4905
- // if the user asks them to set up memory. They'll call this tool,
4906
- // receive a pointer to `openclaw totalreclaw onboard`, and the flow
4907
- // continues on the user's TTY.
5106
+ // Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
5107
+ // facilitated setup surface is ``totalreclaw_pair`` (browser-side
5108
+ // crypto keeps the phrase out of the LLM round-trip by construction).
5109
+ // rc.5 deletes both registrations outright. The underlying CLI
5110
+ // wizard (``openclaw totalreclaw onboard``) is unchanged — users
5111
+ // run it in their own terminal, outside any agent shell.
4908
5112
  //
4909
- // The `recovery_phrase` param is kept in the schema so existing tool
4910
- // calls parse but ANY phrase the caller passes is rejected, and the
4911
- // tool NEVER writes credentials.json. All real setup happens in the
4912
- // CLI wizard.
4913
- api.registerTool(
4914
- {
4915
- name: 'totalreclaw_setup',
4916
- label: 'TotalReclaw setup (deprecated — redirect to CLI)',
4917
- description:
4918
- 'DEPRECATED in 3.2.0. This tool no longer accepts recovery phrases or performs ' +
4919
- 'setup. It returns a pointer to `openclaw totalreclaw onboard` — the secure CLI ' +
4920
- 'wizard that runs on the user\'s terminal so the phrase never touches the LLM ' +
4921
- 'provider. Prefer calling `totalreclaw_onboarding_start` for the same pointer.',
4922
- parameters: {
4923
- type: 'object',
4924
- properties: {
4925
- recovery_phrase: {
4926
- type: 'string',
4927
- description:
4928
- 'Legacy parameter — IGNORED in 3.2.0. If provided, the tool returns a ' +
4929
- 'security warning explaining that phrases must never be pasted through ' +
4930
- 'chat. Use the `openclaw totalreclaw onboard` CLI wizard to import an ' +
4931
- 'existing phrase safely.',
4932
- },
4933
- },
4934
- additionalProperties: false,
4935
- },
4936
- async execute(_toolCallId: string, params: { recovery_phrase?: string }) {
4937
- // Phrase-passing is a security boundary violation in 3.2.0. Reject
4938
- // with a message that explains WHY — the LLM might try again with
4939
- // a different shape otherwise.
4940
- if (typeof params?.recovery_phrase === 'string' && params.recovery_phrase.trim().length > 0) {
4941
- api.logger.warn(
4942
- 'totalreclaw_setup: rejected phrase-passing call (3.2.0 deprecation).',
4943
- );
4944
- return {
4945
- content: [{
4946
- type: 'text',
4947
- text:
4948
- 'For security, TotalReclaw no longer accepts a recovery phrase through ' +
4949
- 'chat. Pasting a phrase into this tool would ship it to the LLM provider, ' +
4950
- 'which defeats the whole point of end-to-end encryption.\n\n' +
4951
- 'Ask the user to open a terminal on their machine and run:\n\n' +
4952
- ' openclaw totalreclaw onboard\n\n' +
4953
- 'The wizard imports an existing phrase via a hidden stdin prompt that ' +
4954
- 'never touches the LLM, the transcript, or the network.',
4955
- }],
4956
- };
4957
- }
4958
-
4959
- // No-arg call against an already-active state: confirm + move on.
4960
- const state = resolveOnboardingState(CREDENTIALS_PATH, CONFIG.onboardingStatePath);
4961
- if (state.onboardingState === 'active') {
4962
- return {
4963
- content: [{
4964
- type: 'text',
4965
- text:
4966
- 'TotalReclaw is already set up and active on this machine. Memory tools ' +
4967
- 'are unblocked — you can call `totalreclaw_remember` and `totalreclaw_recall` ' +
4968
- 'directly. If the user wants to rotate phrases, have them delete ' +
4969
- '`~/.totalreclaw/credentials.json` and run `openclaw totalreclaw onboard` again.',
4970
- }],
4971
- };
4972
- }
4973
-
4974
- // Fresh state, no phrase: redirect to the CLI wizard.
4975
- return {
4976
- content: [{
4977
- type: 'text',
4978
- text:
4979
- 'TotalReclaw setup must run on the user\'s local terminal so the recovery ' +
4980
- 'phrase never touches the LLM. Ask the user to open a terminal and run:\n\n' +
4981
- ' openclaw totalreclaw onboard\n\n' +
4982
- 'The wizard walks through generate-new-phrase or import-existing-phrase. ' +
4983
- 'After it completes, memory tools become available automatically. See the ' +
4984
- '`totalreclaw_onboarding_start` tool for the same pointer in a more ' +
4985
- 'discoverable shape.',
4986
- }],
4987
- };
4988
- },
4989
- },
4990
- { name: 'totalreclaw_setup' },
4991
- );
4992
-
4993
- // ---------------------------------------------------------------
4994
- // Tool: totalreclaw_onboarding_start (3.2.0 pointer-only tool)
4995
- // ---------------------------------------------------------------
5113
+ // Audit assertion: ``phrase-safety-registry.test.ts`` asserts
5114
+ // neither name is present in the ``api.registerTool`` call list.
5115
+ // Re-adding either fails CI.
4996
5116
  //
4997
- // When the user asks the LLM to set up TotalReclaw, this tool directs
4998
- // them to the CLI wizard. The response body is a non-secret pointer —
4999
- // it NEVER contains a recovery phrase — so it can safely flow through
5000
- // the LLM provider and the transcript.
5001
- api.registerTool(
5002
- {
5003
- name: 'totalreclaw_onboarding_start',
5004
- label: 'TotalReclaw — start onboarding',
5005
- description:
5006
- 'Call this when the user wants to set up TotalReclaw memory or asks about ' +
5007
- 'enabling memory features. This tool does NOT generate, display, or accept ' +
5008
- 'a recovery phrase — it returns a short pointer that tells the user to run ' +
5009
- 'the onboarding wizard in their local terminal. All phrase handling happens ' +
5010
- 'outside the LLM. If TotalReclaw is already active, the tool returns a ' +
5011
- 'confirmation.',
5012
- parameters: {
5013
- type: 'object',
5014
- properties: {},
5015
- additionalProperties: false,
5016
- },
5017
- async execute() {
5018
- const state = resolveOnboardingState(CREDENTIALS_PATH, CONFIG.onboardingStatePath);
5019
- if (state.onboardingState === 'active') {
5020
- return {
5021
- content: [{
5022
- type: 'text',
5023
- text:
5024
- 'TotalReclaw is already set up on this machine. Your encryption keys are ' +
5025
- 'ready — `totalreclaw_remember`, `totalreclaw_recall`, and the other memory ' +
5026
- 'tools are unblocked. Run `openclaw totalreclaw status` in a terminal for ' +
5027
- 'more detail.',
5028
- }],
5029
- };
5030
- }
5031
- return {
5032
- content: [{
5033
- type: 'text',
5034
- text:
5035
- 'TotalReclaw onboarding requires a local terminal so your recovery phrase ' +
5036
- 'never touches the LLM provider. On the same machine as your OpenClaw ' +
5037
- 'gateway, open a terminal and run:\n\n' +
5038
- ' openclaw totalreclaw onboard\n\n' +
5039
- 'The wizard will ask whether you want to generate a new phrase or import an ' +
5040
- 'existing TotalReclaw phrase. Both paths display/accept the phrase only on ' +
5041
- 'your terminal — nothing crosses the network. After the wizard completes, ' +
5042
- 'come back here and I\'ll be able to use `totalreclaw_remember` and ' +
5043
- '`totalreclaw_recall`.',
5044
- }],
5045
- };
5046
- },
5047
- },
5048
- { name: 'totalreclaw_onboarding_start' },
5049
- );
5117
+ // Historical tombstone (so LLM-assisted contributors don't re-add
5118
+ // the former shape from training-data memory): rc.4 registered two
5119
+ // tools by the names "totalreclaw_setup" and
5120
+ // "totalreclaw_onboarding_start" as pointer-only stubs. Both were
5121
+ // deleted in rc.5. Do not re-introduce.
5050
5122
 
5051
5123
  // ---------------------------------------------------------------
5052
5124
  // Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
@@ -5122,18 +5194,117 @@ const plugin = {
5122
5194
  const rawMode = params?.mode;
5123
5195
  const mode: 'generate' | 'import' =
5124
5196
  rawMode === 'import' ? 'import' : 'generate';
5197
+ const pairMode = CONFIG.pairMode;
5125
5198
  try {
5126
- const { createPairSession } = await import('./pair-session-store.js');
5127
- const { generateGatewayKeypair } = await import('./pair-crypto.js');
5199
+ // 3.3.1-rc.11 relay-brokered pair by default (universal reachability).
5200
+ // `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
5201
+ // for air-gapped / self-hosted setups. Both paths return the same
5202
+ // tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
5203
+ // only the URL origin differs.
5204
+ let url: string;
5205
+ let pin: string;
5206
+ let sidOrToken: string;
5207
+ let expiresAtMs: number;
5208
+ let localSession: import('./pair-session-store.js').PairSession | undefined;
5209
+
5210
+ if (pairMode === 'relay') {
5211
+ const { openRemotePairSession, awaitPhraseUpload } = await import(
5212
+ './pair-remote-client.js'
5213
+ );
5214
+ const remoteSession = await openRemotePairSession({
5215
+ relayBaseUrl: CONFIG.pairRelayUrl,
5216
+ mode: mode === 'generate' ? 'generate' : 'import',
5217
+ });
5218
+ url = remoteSession.url;
5219
+ pin = remoteSession.pin;
5220
+ sidOrToken = remoteSession.token;
5221
+ // Relay sends ISO-8601; convert to ms for tool payload parity.
5222
+ const parsed = Date.parse(remoteSession.expiresAt);
5223
+ expiresAtMs = Number.isFinite(parsed)
5224
+ ? parsed
5225
+ : Date.now() + 5 * 60_000;
5226
+ // Background task — writes credentials.json + flips state when
5227
+ // the browser completes the flow. Tool handler returns
5228
+ // immediately so the agent can tell the user the URL + PIN.
5229
+ void (async () => {
5230
+ try {
5231
+ await awaitPhraseUpload(remoteSession, {
5232
+ phraseValidator: (p: string) =>
5233
+ validateMnemonic(p, wordlist),
5234
+ completePairing: async ({ mnemonic }) => {
5235
+ try {
5236
+ // 3.3.1 (internal#130) — derive + persist the
5237
+ // Smart Account address now so the user can see
5238
+ // it immediately after pair, before any chain
5239
+ // write. Mnemonic stays in this scope (already
5240
+ // on disk in credentials.json); only the
5241
+ // derived public scope_address is added.
5242
+ let scopeAddress: string | undefined;
5243
+ try {
5244
+ scopeAddress = await deriveSmartAccountAddress(
5245
+ mnemonic,
5246
+ CONFIG.chainId,
5247
+ );
5248
+ } catch (deriveErr) {
5249
+ api.logger.warn(
5250
+ `totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
5251
+ );
5252
+ }
5253
+ const creds =
5254
+ loadCredentialsJson(CREDENTIALS_PATH) ?? {};
5255
+ const next: typeof creds = { ...creds, mnemonic };
5256
+ if (scopeAddress) next.scope_address = scopeAddress;
5257
+ if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
5258
+ return { state: 'error', error: 'credentials_write_failed' };
5259
+ }
5260
+ setRecoveryPhraseOverride(mnemonic);
5261
+ writeOnboardingState(CONFIG.onboardingStatePath, {
5262
+ onboardingState: 'active',
5263
+ createdBy: mode === 'generate' ? 'generate' : 'import',
5264
+ credentialsCreatedAt: new Date().toISOString(),
5265
+ version: pluginVersion ?? '3.3.0',
5266
+ });
5267
+ api.logger.info(
5268
+ `totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`,
5269
+ );
5270
+ return { state: 'active' };
5271
+ } catch (err: unknown) {
5272
+ const msg = err instanceof Error ? err.message : String(err);
5273
+ api.logger.error(
5274
+ `totalreclaw_pair(relay): completePairing failed: ${msg}`,
5275
+ );
5276
+ return { state: 'error', error: msg };
5277
+ }
5278
+ },
5279
+ });
5280
+ } catch (bgErr: unknown) {
5281
+ // Expected on TTL expiry / user-aborts — log at warn, not error.
5282
+ const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
5283
+ api.logger.warn(
5284
+ `totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`,
5285
+ );
5286
+ }
5287
+ })();
5288
+ } else {
5289
+ // Local loopback path (rc.10 behaviour).
5290
+ const { createPairSession } = await import('./pair-session-store.js');
5291
+ const { generateGatewayKeypair } = await import('./pair-crypto.js');
5292
+ const kp = generateGatewayKeypair();
5293
+ const session = await createPairSession(CONFIG.pairSessionsPath, {
5294
+ mode,
5295
+ operatorContext: { channel: 'agent' },
5296
+ rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5297
+ rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5298
+ });
5299
+ url = buildPairingUrl(api, session);
5300
+ pin = session.secondaryCode;
5301
+ sidOrToken = session.sid;
5302
+ expiresAtMs = session.expiresAtMs;
5303
+ localSession = session;
5304
+ }
5305
+
5306
+ // QR renderers — same for both modes; input is the URL string.
5128
5307
  const { defaultRenderQr } = await import('./pair-cli.js');
5129
- const kp = generateGatewayKeypair();
5130
- const session = await createPairSession(CONFIG.pairSessionsPath, {
5131
- mode,
5132
- operatorContext: { channel: 'agent' },
5133
- rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5134
- rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5135
- });
5136
- const url = buildPairingUrl(api, session);
5137
5308
  const qrAscii = await new Promise<string>((resolve) => {
5138
5309
  let settled = false;
5139
5310
  const t = setTimeout(() => {
@@ -5156,16 +5327,40 @@ const plugin = {
5156
5327
  resolve('');
5157
5328
  }
5158
5329
  });
5330
+
5331
+ // 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
5332
+ let qrPngB64 = '';
5333
+ let qrUnicode = '';
5334
+ try {
5335
+ const { encodePng, encodeUnicode } = await import('./pair-qr.js');
5336
+ const [pngBuf, uni] = await Promise.all([
5337
+ encodePng(url),
5338
+ encodeUnicode(url),
5339
+ ]);
5340
+ qrPngB64 = pngBuf.toString('base64');
5341
+ qrUnicode = uni;
5342
+ } catch (qrErr: unknown) {
5343
+ api.logger.warn(
5344
+ `totalreclaw_pair: QR encode failed (non-fatal): ${
5345
+ qrErr instanceof Error ? qrErr.message : String(qrErr)
5346
+ }`,
5347
+ );
5348
+ }
5349
+
5159
5350
  api.logger.info(
5160
- `totalreclaw_pair: session ${session.sid.slice(0, 8)}… mode=${mode} url=${url}`,
5351
+ `totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`,
5161
5352
  );
5353
+ // Voidly reference localSession so TS does not flag the unused
5354
+ // local-branch binding. Future rc.12 diagnostics can expose
5355
+ // `session.mode` / `session.status` separately.
5356
+ void localSession;
5162
5357
  return {
5163
5358
  content: [{
5164
5359
  type: 'text',
5165
5360
  text:
5166
5361
  `Pairing session started.\n\n` +
5167
5362
  `URL: ${url}\n\n` +
5168
- `PIN (type this into the browser): ${session.secondaryCode}\n\n` +
5363
+ `PIN (type this into the browser): ${pin}\n\n` +
5169
5364
  (qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
5170
5365
  `Instructions for the user:\n` +
5171
5366
  `1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
@@ -5179,12 +5374,18 @@ const plugin = {
5179
5374
  `This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
5180
5375
  }],
5181
5376
  details: {
5182
- sid: session.sid,
5377
+ sid: sidOrToken,
5183
5378
  url,
5184
- pin: session.secondaryCode,
5379
+ pin,
5185
5380
  mode,
5186
- expires_at_ms: session.expiresAtMs,
5381
+ expires_at_ms: expiresAtMs,
5187
5382
  qr_ascii: qrAscii,
5383
+ qr_png_b64: qrPngB64,
5384
+ qr_unicode: qrUnicode,
5385
+ // rc.11 — surface the transport so downstream tooling (QA
5386
+ // harness asserters, telemetry) can confirm which path
5387
+ // served the URL. Either 'relay' or 'local'.
5388
+ transport: pairMode,
5188
5389
  },
5189
5390
  };
5190
5391
  } catch (err: unknown) {
@@ -5199,6 +5400,25 @@ const plugin = {
5199
5400
  },
5200
5401
  { name: 'totalreclaw_pair' },
5201
5402
  );
5403
+ // 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
5404
+ // ops/QA can grep gateway logs for definitive proof the tool was
5405
+ // declared. If the agent then reports the tool is missing from its
5406
+ // tool list, the gap is upstream OpenClaw tool propagation, not our
5407
+ // plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
5408
+ //
5409
+ // 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
5410
+ // bleeding into `openclaw agent --json` stdout, breaking programmatic
5411
+ // parsers that expect the JSON-RPC body to be the only thing on the
5412
+ // wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
5413
+ // `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
5414
+ // a tool-injection regression. Default OFF — clean stdout for users.
5415
+ if (CONFIG.verboseRegister) {
5416
+ api.logger.info(
5417
+ 'TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
5418
+ 'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
5419
+ '`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.',
5420
+ );
5421
+ }
5202
5422
 
5203
5423
  // ---------------------------------------------------------------
5204
5424
  // Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
@@ -5295,10 +5515,16 @@ const plugin = {
5295
5515
  details: { error: 'missing_github_token' },
5296
5516
  };
5297
5517
  }
5518
+ // rc.14 — `repo` is resolved inside `postQaBugIssue` via
5519
+ // `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
5520
+ // refuses any slug that isn't a `-internal` fork. Pass the
5521
+ // config-surfaced override so env reads stay in config.ts.
5522
+ const repoOverride = CONFIG.qaRepoOverride || undefined;
5298
5523
  const result = await postQaBugIssue(
5299
5524
  params as unknown as import('./qa-bug-report.js').QaBugArgs,
5300
5525
  {
5301
5526
  githubToken: token,
5527
+ repo: repoOverride,
5302
5528
  logger: api.logger,
5303
5529
  },
5304
5530
  );
@@ -5324,9 +5550,15 @@ const plugin = {
5324
5550
  },
5325
5551
  { name: 'totalreclaw_report_qa_bug' },
5326
5552
  );
5327
- api.logger.info(
5328
- 'totalreclaw_report_qa_bug registered (RC build this tool is hidden in stable releases).',
5329
- );
5553
+ // 3.3.1-rc.21 (issue #128): demote the registration-confirmation
5554
+ // breadcrumb to verbose-only. Same `--json` stdout pollution risk
5555
+ // as the totalreclaw_pair breadcrumb above; ops can opt back in
5556
+ // via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
5557
+ if (CONFIG.verboseRegister) {
5558
+ api.logger.info(
5559
+ 'totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).',
5560
+ );
5561
+ }
5330
5562
  }
5331
5563
 
5332
5564
  // ---------------------------------------------------------------
@@ -5421,11 +5653,18 @@ const plugin = {
5421
5653
  '## TotalReclaw setup pending\n\n' +
5422
5654
  'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
5423
5655
  'If the user asks about memory features or wants to configure TotalReclaw, ' +
5424
- 'call the `totalreclaw_onboarding_start` tool for instructions.\n\n' +
5656
+ 'call the `totalreclaw_pair` tool it returns a pairing URL + 6-digit PIN ' +
5657
+ 'the user opens in a browser (phrase is generated and encrypted in-browser, ' +
5658
+ 'never leaves their device in plaintext). If `totalreclaw_pair` is not in ' +
5659
+ 'your tool list (container-based agents sometimes miss it), shell out to ' +
5660
+ '`openclaw totalreclaw pair generate --url-pin-only` on the gateway host — ' +
5661
+ 'it emits a single JSON line `{v,url,pin,expires_at_ms}` with no phrase ' +
5662
+ 'material; hand the user the `url` and `pin`.\n\n' +
5425
5663
  '**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
5426
- 'All phrase handling happens on the user\'s local terminal via ' +
5427
- '`openclaw totalreclaw onboard` this keeps the phrase out of the LLM ' +
5428
- 'provider\'s logs.',
5664
+ '**Do NOT** run `openclaw totalreclaw onboard` that CLI emits the recovery ' +
5665
+ 'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
5666
+ '(tool or `--url-pin-only` CLI) instead; `onboard` is reserved for users ' +
5667
+ 'running it directly in their own local terminal.',
5429
5668
  };
5430
5669
  }
5431
5670
 
@@ -5455,7 +5694,7 @@ const plugin = {
5455
5694
  const walletParam = encodeURIComponent(subgraphOwner || userId || '');
5456
5695
  const billingResp = await fetch(`${billingUrl}/v1/billing/status?wallet_address=${walletParam}`, {
5457
5696
  method: 'GET',
5458
- headers: { 'Authorization': `Bearer ${authKeyHex}`, 'Accept': 'application/json', 'X-TotalReclaw-Client': 'openclaw-plugin' },
5697
+ headers: buildRelayHeaders({ 'Authorization': `Bearer ${authKeyHex}`, 'Accept': 'application/json' }),
5459
5698
  });
5460
5699
  if (billingResp.ok) {
5461
5700
  const billingData = await billingResp.json() as Record<string, unknown>;
@@ -5692,17 +5931,7 @@ const plugin = {
5692
5931
 
5693
5932
  if (reranked.length === 0) return undefined;
5694
5933
 
5695
- // 6b. Cosine similarity threshold gate skip injection when the
5696
- // best match is below the minimum relevance threshold.
5697
- const hookMaxCosine = Math.max(
5698
- ...reranked.map((r) => r.cosineSimilarity ?? 0),
5699
- );
5700
- if (hookMaxCosine < COSINE_THRESHOLD) {
5701
- api.logger.info(
5702
- `Hook: cosine threshold gate filtered results (max=${hookMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
5703
- );
5704
- return undefined;
5705
- }
5934
+ // Relevance gate removed in rc.22 (see recall tool comment).
5706
5935
 
5707
5936
  // 7. Build context string.
5708
5937
  const lines = reranked.map((m, i) => {
@@ -5807,17 +6036,7 @@ const plugin = {
5807
6036
 
5808
6037
  if (reranked.length === 0) return undefined;
5809
6038
 
5810
- // Cosine similarity threshold gate skip injection when the
5811
- // best match is below the minimum relevance threshold.
5812
- const srvMaxCosine = Math.max(
5813
- ...reranked.map((r) => r.cosineSimilarity ?? 0),
5814
- );
5815
- if (srvMaxCosine < COSINE_THRESHOLD) {
5816
- api.logger.info(
5817
- `Hook: cosine threshold gate filtered results (max=${srvMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
5818
- );
5819
- return undefined;
5820
- }
6039
+ // Relevance gate removed in rc.22 (see recall tool comment).
5821
6040
 
5822
6041
  // 7. Build context string.
5823
6042
  const lines = reranked.map((m, i) => {