@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

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 (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
package/dist/index.js CHANGED
@@ -8,37 +8,55 @@
8
8
  /**
9
9
  * TotalReclaw Plugin for OpenClaw
10
10
  *
11
- * Registers runtime tools so OpenClaw can execute TotalReclaw operations:
12
- * - totalreclaw_remember -- store an encrypted memory
13
- * - totalreclaw_recall -- search and decrypt memories
14
- * - totalreclaw_forget -- soft-delete a memory
15
- * - totalreclaw_export -- export all memories (JSON or Markdown)
16
- * - totalreclaw_status -- check billing/subscription status
17
- * - totalreclaw_consolidate -- scan and merge near-duplicate memories
18
- * - totalreclaw_pin -- pin a memory so auto-resolution can never supersede it
19
- * - totalreclaw_unpin -- remove a pin, returning the memory to active status
20
- * - totalreclaw_import_from -- import memories from other tools (Mem0, MCP Memory, etc.)
21
- * - totalreclaw_import_status -- check background import progress
22
- * - totalreclaw_import_abort -- cancel a running background import
23
- * - totalreclaw_upgrade -- create Stripe checkout for Pro upgrade
24
- * - totalreclaw_migrate -- migrate testnet memories to mainnet after Pro upgrade
25
- * - totalreclaw_onboarding_start -- non-secret pointer to the CLI wizard (3.2.0)
26
- * - totalreclaw_setup -- DEPRECATED in 3.2.0; redirects to the CLI wizard
11
+ * Phase 3 OpenClaw native integration. The agent reads memories via the
12
+ * bundled NATIVE tools `memory_search` / `memory_get`, registered through
13
+ * the host's MemoryPluginCapability by `registerNativeMemory` (see
14
+ * `native-memory.ts`). The TrMemorySearchManager adapter binds those tools
15
+ * to TotalReclaw's encrypted subgraph + decrypt + reranker pipeline.
16
+ *
17
+ * The legacy `totalreclaw_*` agent tools (remember / recall / forget / export
18
+ * / status / pin / unpin / retype / set_scope / import_from / import_status
19
+ * / import_abort / upgrade / migrate / onboarding_start / setup /
20
+ * report_qa_bug) were RETIRED in Task 3.2. Their capabilities now live on:
21
+ * - read (recall): native `memory_search` / `memory_get`.
22
+ * - explicit write: CLI `tr remember` (the conventional memory
23
+ * contract ships no agent-facing write tool;
24
+ * auto-extraction handles implicit writes).
25
+ * - curation/lifecycle: CLI `tr forget` / `tr export` + the
26
+ * registerCli `openclaw totalreclaw ...` surface.
27
+ * - import/upgrade: registerCli `openclaw totalreclaw import from ...`
28
+ * / `import status` / `import abort` / `upgrade`
29
+ * (3.3.13 — restored as CLI surfaces; the handlers
30
+ * stayed in this file post-3.2 but had no entry
31
+ * point). NOT on the standalone `tr` binary —
32
+ * import needs the full extraction pipeline that
33
+ * only loads inside the gateway runtime.
34
+ * - onboarding/pair: CLI `tr pair` + the 4 `/plugin/totalreclaw/pair/*`
35
+ * HTTP routes (registerHttpRoute bundle).
36
+ *
37
+ * Hooks registered here:
38
+ * - `before_agent_start` — injects relevant memories into the agent's
39
+ * context (via the MemoryPluginCapability's promptBuilder) and a
40
+ * non-secret onboarding hint when the user has not paired yet.
41
+ * - `before_tool_call` — gates the native `memory_search` / `memory_get`
42
+ * tools behind onboarding state `active` so an unpaired agent gets an
43
+ * actionable pointer to `tr pair --url-pin` instead of the adapter's
44
+ * silent fail-soft empty result (see `tool-gating.ts`).
45
+ * - `agent_end`, `message_received`, `before_reset` — auto-extraction
46
+ * + digest bookkeeping (unchanged).
27
47
  *
28
48
  * Also registers:
29
- * - `before_agent_start` hook that automatically injects relevant memories
30
- * into the agent's context (and a non-secret onboarding hint when the
31
- * user has not completed the CLI setup yet).
32
- * - `before_tool_call` hook that gates every memory tool until onboarding
33
- * state is `active` (3.2.0).
34
- * - `registerCli` subcommand `openclaw totalreclaw onboard` the ONLY
35
- * surface that generates or accepts a recovery phrase. Lives entirely on
36
- * the user's terminal; the phrase never enters an LLM request or a
37
- * session transcript.
49
+ * - `registerCli` subcommand `openclaw totalreclaw ...` pair / onboard /
50
+ * status / pin / unpin / retype / set_scope / import / export. The
51
+ * `onboard` subcommand is the ONLY surface that generates or accepts a
52
+ * recovery phrase; it lives entirely on the user's terminal and the
53
+ * phrase never enters an LLM request or a session transcript.
54
+ * - `registerHttpRoute` the 4 QR-pair endpoints under
55
+ * `/plugin/totalreclaw/pair/*` (browser-facing, plugin-auth).
38
56
  * - `registerCommand` slash command `/totalreclaw {onboard,status}` — a
39
- * non-secret pointer that directs the user to the CLI wizard.
57
+ * non-secret pointer to the CLI wizard.
40
58
  *
41
- * Security: in 3.2.0, the recovery phrase NEVER appears in tool responses,
59
+ * Security: the recovery phrase NEVER appears in tool responses,
42
60
  * `prependContext` blocks, slash-command replies, or any other surface that
43
61
  * is sent to the LLM provider or persisted to the session transcript. See
44
62
  * `docs/plans/2026-04-20-plugin-320-secure-onboarding.md` in the internal
@@ -49,29 +67,24 @@
49
67
  */
50
68
  import { deriveKeys, deriveLshSeed, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
51
69
  import { createApiClient } from './api-client.js';
52
- import { extractFacts, extractCrystal, isValidMemoryType, parseEntity, VALID_MEMORY_TYPES, LEGACY_V0_MEMORY_TYPES, VALID_MEMORY_SOURCES, VALID_MEMORY_SCOPES, EXTRACTION_SYSTEM_PROMPT, extractFactsForCompaction, } from './extractor.js';
70
+ import { extractFacts, extractCrystal, EXTRACTION_SYSTEM_PROMPT, extractFactsForCompaction, } from './extractor.js';
53
71
  import { initLLMClient, resolveLLMConfig, chatCompletion, generateEmbedding, getEmbeddingDims, getEmbeddingModelId, configureEmbedder, prefetchEmbedderBundle, } from './llm-client.js';
54
72
  import { defaultAuthProfilesRoot, readAllProfileKeys, dedupeByProvider, } from './llm-profile-reader.js';
55
73
  import { LSHHasher } from './lsh.js';
56
74
  import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS } from './reranker.js';
57
75
  import { deduplicateBatch } from './semantic-dedup.js';
58
76
  import { startTrajectoryPoller } from './trajectory-poller.js';
59
- import { findNearDuplicate, shouldSupersede, clusterFacts, getStoreDedupThreshold, getConsolidationThreshold, STORE_DEDUP_MAX_CANDIDATES, } from './consolidation.js';
77
+ import { findNearDuplicate, shouldSupersede, getStoreDedupThreshold, STORE_DEDUP_MAX_CANDIDATES, } from './consolidation.js';
60
78
  import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4 } from './subgraph-store.js';
61
- import { confirmIndexed } from './confirm-indexed.js';
62
- import { DIGEST_TRAPDOOR, buildCanonicalClaim, computeEntityTrapdoor, computeEntityTrapdoors, isDigestBlob, normalizeToV1Type, readClaimFromBlob, resolveDigestMode, } from './claims-helper.js';
79
+ import { DIGEST_TRAPDOOR, buildCanonicalClaim, computeEntityTrapdoors, isDigestBlob, readClaimFromBlob, resolveDigestMode, } from './claims-helper.js';
63
80
  import { maybeInjectDigest, recompileDigest, fetchAllActiveClaims, isRecompileInProgress, tryBeginRecompile, endRecompile, } from './digest-sync.js';
64
81
  import { detectAndResolveContradictions, runWeightTuningLoop, } from './contradiction-sync.js';
65
82
  import { searchSubgraph, searchSubgraphBroadened, getSubgraphFactCount, fetchFactById } from './subgraph-search.js';
66
- import { executePinOperation, validatePinArgs, } from './pin.js';
67
- import { executeRetype, executeSetScope, validateRetypeArgs, validateSetScopeArgs, } from './retype-setscope.js';
68
83
  import { PluginHotCache } from './hot-cache-wrapper.js';
69
84
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
70
85
  import { buildRelayHeaders } from './relay-headers.js';
71
86
  import { readBillingCache, writeBillingCache, BILLING_CACHE_PATH, } from './billing-cache.js';
72
- import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, patchOpenClawConfig, writePluginManifest, writePluginError, readPluginLoadedManifest, checkCredentialsFileMode, deletePairPendingFile, defaultPairPendingPath, } from './fs-helpers.js';
73
- import { maybeStartAutoPair } from './auto-pair-on-load.js';
74
- import { installBeforeAgentStartHook as installPairPendingHook } from './pair-pending-injection.js';
87
+ import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, patchOpenClawConfig, checkCredentialsFileMode, } from './fs-helpers.js';
75
88
  import { isRcBuild } from './qa-bug-report.js';
76
89
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
77
90
  import { resolveRestartAuth, rejectMessageFor, } from './restart-auth.js';
@@ -79,6 +92,8 @@ import { recordInboundUser, getDistinctInboundUserCount, resolveTrackerPath, } f
79
92
  import { detectFirstRun, buildWelcomePrepend } from './first-run.js';
80
93
  import { buildPairRoutes } from './pair-http.js';
81
94
  import { detectGatewayHost } from './gateway-url.js';
95
+ import { registerNativeMemory } from './native-memory.js';
96
+ import { ensureSkillRegistered } from './skill-register.js';
82
97
  import { validateMnemonic } from '@scure/bip39';
83
98
  import { wordlist } from '@scure/bip39/wordlists/english.js';
84
99
  import crypto from 'node:crypto';
@@ -215,15 +230,17 @@ function buildPairingUrl(api, session) {
215
230
  // issue #110 fix 4: inside Docker the LAN IP is container-internal
216
231
  // and useless. Loopback localhost only works for `docker exec`
217
232
  // tests. The CORRECT pair URL for Docker is the relay-brokered
218
- // path served by the `totalreclaw_pair` agent tool (CONFIG.pairMode
219
- // === 'relay' since rc.11). The CLI-only path here cannot mint a
220
- // relay session synchronously (the relay handshake needs a WS
221
- // round-trip), so we emit the loopback URL with a LOUD warning
222
- // pointing the operator at the agent tool / publicUrl override.
233
+ // path served by `tr pair` / the `/plugin/totalreclaw/pair/*` HTTP
234
+ // routes (CONFIG.pairMode === 'relay' since rc.11). The CLI-only
235
+ // path here cannot mint a relay session synchronously (the relay
236
+ // handshake needs a WS round-trip), so we emit the loopback URL
237
+ // with a LOUD warning pointing the operator at the pair CLI /
238
+ // publicUrl override.
223
239
  api.logger.warn(`TotalReclaw: Docker container detected — pairing URL falling back to ` +
224
240
  `http://localhost:${port}, which is unreachable from the host browser. ` +
225
- `Use the totalreclaw_pair AGENT TOOL (relay-brokered, universally reachable) ` +
226
- `instead of the CLI fallback, OR set plugins.entries.totalreclaw.config.publicUrl ` +
241
+ `Run \`tr pair --url-pin\` (or \`openclaw totalreclaw pair generate --url-pin-only\`) ` +
242
+ `on the gateway host to mint a relay-brokered pair URL that reaches the host browser, ` +
243
+ `OR set plugins.entries.totalreclaw.config.publicUrl ` +
227
244
  `to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
228
245
  `Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
229
246
  `air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`);
@@ -406,6 +423,11 @@ function getMaxFactsPerExtraction() {
406
423
  const cache = readBillingCache();
407
424
  if (cache?.features?.max_facts_per_extraction != null)
408
425
  return cache.features.max_facts_per_extraction;
426
+ // Env override is read in config.ts (env access is centralized there so
427
+ // the env-harvesting scanner-sim stays a no-op for index.ts).
428
+ if (CONFIG.maxFactsPerExtraction && CONFIG.maxFactsPerExtraction > 0) {
429
+ return CONFIG.maxFactsPerExtraction;
430
+ }
409
431
  return MAX_FACTS_PER_EXTRACTION;
410
432
  }
411
433
  /**
@@ -422,9 +444,10 @@ const MEMORY_HEADER = `# Memory
422
444
 
423
445
  > **TotalReclaw is active.** Your encrypted memories are loaded automatically
424
446
  > at the start of each conversation — no need to search this file for them.
425
- > Use \`totalreclaw_remember\` to store new memories and \`totalreclaw_recall\`
426
- > to search. Do NOT write user facts, preferences, or decisions to this file.
427
- > This file is for workspace-level notes only (non-sensitive).
447
+ > Recall is automatic via the memory_search tool; to explicitly store a fact,
448
+ > run \`tr remember "<text>"\` in a shell. Do NOT write user facts,
449
+ > preferences, or decisions to this file. This file is for workspace-level
450
+ > notes only (non-sensitive).
428
451
 
429
452
  `;
430
453
  function ensureMemoryHeader(logger) {
@@ -574,13 +597,12 @@ async function initialize(logger) {
574
597
  }
575
598
  if (!masterPassword) {
576
599
  needsSetup = true;
577
- // 3.3.13replaced the stale "run `openclaw totalreclaw onboard` in a
578
- // terminal to set up" log line. The auto-pair-on-load flow (kicked off
579
- // from register()) now writes ~/.totalreclaw/.pair-pending.json with
580
- // the URL + PIN the agent surfaces to the user on the next turn. The
581
- // chat agent no longer hallucinates pair URLs — see the Pop-OS QA
582
- // 2026-05-10 root-cause note in auto-pair-on-load.ts.
583
- logger.info('TotalReclaw: no credentials found; auto-pair-on-load will surface a pair URL.');
600
+ // No credentials yet pairing is user-initiated via `tr pair` or the
601
+ // `/plugin/totalreclaw/pair/*` HTTP route (QR scan flow). The plugin
602
+ // does not auto-open a pair session on load (the 3.3.13 auto-pair-on-
603
+ // load state machine was retired in Phase 3.4 pairing is now
604
+ // explicitly user-triggered per the native-integration design).
605
+ logger.info('TotalReclaw: no credentials found. Run `tr pair` (or ask the agent to) to complete setup.');
584
606
  return;
585
607
  }
586
608
  apiClient = createApiClient(serverUrl);
@@ -712,12 +734,16 @@ async function initialize(logger) {
712
734
  const billingData = await resp.json();
713
735
  const tier = billingData.tier;
714
736
  const expiresAt = billingData.expires_at;
715
- // Populate billing cache for future use.
737
+ // Populate billing cache for future use. Copy the relay's
738
+ // authoritative chain_id + data_edge_address so they land on disk and
739
+ // drive the runtime chain + DataEdge overrides verbatim (#402, #460).
716
740
  writeBillingCache({
717
741
  tier: tier || 'free',
718
742
  free_writes_used: billingData.free_writes_used ?? 0,
719
743
  free_writes_limit: billingData.free_writes_limit ?? 0,
720
744
  features: billingData.features,
745
+ chain_id: billingData.chain_id,
746
+ data_edge_address: billingData.data_edge_address,
721
747
  checked_at: Date.now(),
722
748
  });
723
749
  if (tier === 'pro' && expiresAt) {
@@ -751,10 +777,16 @@ function isDocker() {
751
777
  return isRunningInDocker();
752
778
  }
753
779
  function buildSetupErrorMsg() {
754
- return 'TotalReclaw setup required. Use the `totalreclaw_setup` tool with a 12-word BIP-39 recovery phrase.\n\n' +
755
- '1. Ask the user if they have an existing recovery phrase, or generate a new one with `npx @totalreclaw/mcp-server setup`.\n' +
756
- '2. Call `totalreclaw_setup` with the phrase no gateway restart needed.\n' +
757
- ' (Optional: set TOTALRECLAW_SELF_HOSTED=true if using your own server instead of the managed service.)';
780
+ // NOTE: the legacy `totalreclaw_setup` agent tool was retired in 3.2.0
781
+ // (phrase-safety: the agent must never accept or relay a recovery phrase).
782
+ // The ONLY setup surface is the QR-pair flow: the agent cannot mint a
783
+ // pair URL itself, so it must direct the user to the CLI. This message is
784
+ // thrown by `requireFullSetup` (currently only reached via dead 3.2 tool
785
+ // handlers; kept accurate so any future caller gets the correct pointer).
786
+ return 'TotalReclaw setup required. Pairing is QR-only — the recovery phrase is generated and encrypted in-browser and never enters this chat.\n\n' +
787
+ 'Run `tr pair --url-pin` on the gateway host (or `openclaw totalreclaw pair generate --url-pin-only`) ' +
788
+ 'and hand the user the returned `url` and `pin`. The user opens the URL in a browser to complete pairing. ' +
789
+ 'Do NOT ask the user for a recovery phrase and do NOT attempt to generate or relay one yourself.';
758
790
  }
759
791
  function buildSetupErrorMsgLegacy() {
760
792
  const base = 'TotalReclaw setup required:\n' +
@@ -783,8 +815,9 @@ const SETUP_ERROR_MSG = buildSetupErrorMsg();
783
815
  * Ensure `initialize()` has completed (runs at most once).
784
816
  *
785
817
  * If `needsSetup` is true after init, attempts a hot-reload from
786
- * credentials.json in case the mnemonic was written there by a
787
- * `totalreclaw_setup` tool call or `npx @totalreclaw/mcp-server setup`.
818
+ * credentials.json in case the mnemonic was just written there by the
819
+ * pair-completion HTTP route (`/plugin/totalreclaw/pair/respond`
820
+ * `completePairing`) or the `tr pair` CLI on another process.
788
821
  */
789
822
  async function ensureInitialized(logger) {
790
823
  if (!initPromise) {
@@ -792,7 +825,7 @@ async function ensureInitialized(logger) {
792
825
  }
793
826
  await initPromise;
794
827
  // Hot-reload: if setup is still needed, check if credentials.json
795
- // now has a mnemonic (written by totalreclaw_setup or MCP setup CLI).
828
+ // now has a mnemonic (written by the pair HTTP route / `tr pair` CLI).
796
829
  if (needsSetup) {
797
830
  await attemptHotReload(logger);
798
831
  }
@@ -801,9 +834,9 @@ async function ensureInitialized(logger) {
801
834
  * Attempt to hot-reload credentials from credentials.json.
802
835
  *
803
836
  * Called when `needsSetup` is true — checks if credentials.json contains
804
- * a mnemonic (written by the `totalreclaw_setup` tool or MCP setup CLI).
805
- * If found, re-derives keys and completes initialization without requiring
806
- * a gateway restart.
837
+ * a mnemonic (written by the pair-completion HTTP route or `tr pair` CLI
838
+ * on another process). If found, re-derives keys and completes
839
+ * initialization without requiring a gateway restart.
807
840
  */
808
841
  async function attemptHotReload(logger) {
809
842
  try {
@@ -827,9 +860,18 @@ async function attemptHotReload(logger) {
827
860
  /**
828
861
  * Force re-initialization with a specific mnemonic.
829
862
  *
830
- * Called by the `totalreclaw_setup` tool. Clears stale credentials from
831
- * disk so that `initialize()` treats this as a fresh registration and
832
- * persists the NEW mnemonic + freshly derived salt/userId.
863
+ * LEGACY (Phase 3.2): the only caller was the `totalreclaw_setup` agent
864
+ * tool, which was retired because accepting a recovery phrase via an agent
865
+ * tool violated phrase-safety (the phrase must never enter an LLM context).
866
+ * The function currently has NO callers; it is retained because the
867
+ * credential-rotation invariant documented below still describes a real
868
+ * trap for any future credential-rotating surface, and removing the body
869
+ * would lose that institutional knowledge. Safe to delete once confirmed
870
+ * unused across the whole plugin tree.
871
+ *
872
+ * Clears stale credentials from disk so that `initialize()` treats this as
873
+ * a fresh registration and persists the NEW mnemonic + freshly derived
874
+ * salt/userId.
833
875
  *
834
876
  * Without clearing credentials.json first, `initialize()` would load the
835
877
  * OLD salt and userId, derive keys from (new mnemonic + old salt), skip
@@ -1400,7 +1442,7 @@ function relativeTime(isoOrMs) {
1400
1442
  * Configurable via TOTALRECLAW_MIN_IMPORTANCE env var (default: 3).
1401
1443
  *
1402
1444
  * NOTE: This filter is ONLY applied to auto-extraction (hooks).
1403
- * The explicit `totalreclaw_remember` tool always stores regardless of importance.
1445
+ * The explicit `tr remember` CLI always stores regardless of importance.
1404
1446
  */
1405
1447
  const MIN_IMPORTANCE_THRESHOLD = CONFIG.minImportance;
1406
1448
  /**
@@ -1768,7 +1810,7 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
1768
1810
  // Submit subgraph payloads one fact at a time (sequential single-call UserOps).
1769
1811
  // Batch executeBatch UserOps have persistent gas estimation issues on Base Sepolia
1770
1812
  // that cause on-chain reverts. Single-fact UserOps use the simpler submitFactOnChain
1771
- // path which works reliably (same path as totalreclaw_remember). Each submission
1813
+ // path which works reliably (same path the `tr remember` CLI uses). Each submission
1772
1814
  // polls for receipt (120s) before proceeding, so nonce is consumed before the next.
1773
1815
  let batchError;
1774
1816
  if (pendingPayloads.length > 0 && isSubgraphMode()) {
@@ -1820,10 +1862,11 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
1820
1862
  return stored;
1821
1863
  }
1822
1864
  // ---------------------------------------------------------------------------
1823
- // Import handler (for totalreclaw_import_from tool)
1865
+ // Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
1824
1866
  // ---------------------------------------------------------------------------
1825
1867
  /**
1826
- * Handle import_from tool calls in the plugin context.
1868
+ * Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
1869
+ * agent tool before Phase 3.2 retired the agent tools).
1827
1870
  *
1828
1871
  * Two paths:
1829
1872
  * 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
@@ -1952,7 +1995,7 @@ async function handlePluginImportFrom(params, logger) {
1952
1995
  estimated_batches: estimatedBatches,
1953
1996
  estimated_minutes: estimatedMinutes,
1954
1997
  estimated_completion_iso: initialState.estimated_completion_iso,
1955
- message: `Import started in background. ~${estimatedMinutes} min for ${totalChunks} chunks. Ask "how's the import?" to check progress with totalreclaw_import_status.`,
1998
+ message: `Import started in background. ~${estimatedMinutes} min for ${totalChunks} chunks. Check progress with \`openclaw totalreclaw import status\` on the gateway host (or \`import status --id ${importId} --json\` from an agent shell).`,
1956
1999
  warnings: parseResult.warnings,
1957
2000
  };
1958
2001
  }
@@ -2412,7 +2455,7 @@ async function handleImportStatus(params, logger) {
2412
2455
  else {
2413
2456
  state = readMostRecentActiveImport();
2414
2457
  if (!state)
2415
- return { status: 'no_active_import', message: 'No active import found. Start one with totalreclaw_import_from.' };
2458
+ return { status: 'no_active_import', message: 'No active import found. Start one with `openclaw totalreclaw import from <source>` on the gateway host. (Auto-resume still picks up running imports on gateway restart.)' };
2416
2459
  }
2417
2460
  // 1h freshness guard: mark stale imports as failed and prompt user to resume.
2418
2461
  if (state.status === 'running' && isImportStale(state)) {
@@ -2423,7 +2466,7 @@ async function handleImportStatus(params, logger) {
2423
2466
  status: 'failed',
2424
2467
  stale: true,
2425
2468
  facts_stored: state.facts_stored,
2426
- message: 'Import appears stale — no progress in 1 hour. Resume with totalreclaw_import_from using the same file and resume_id.',
2469
+ message: 'Import appears stale — no progress in 1 hour. Resume it with `openclaw totalreclaw import from <source> --file <path> --resume ' + state.import_id + '` on the gateway host, or restart the gateway to trigger auto-resume.',
2427
2470
  resume_id: state.import_id,
2428
2471
  };
2429
2472
  }
@@ -2471,6 +2514,323 @@ async function handleImportAbort(params, logger) {
2471
2514
  };
2472
2515
  }
2473
2516
  // ---------------------------------------------------------------------------
2517
+ // buildRecallDeps — bind the real recall pipeline into the closures the
2518
+ // native MemoryPluginCapability wiring helper (Task 2.7) consumes.
2519
+ //
2520
+ // WHY THIS LIVES IN index.ts (not in native-memory.ts):
2521
+ // The real `recall` / `getById` closures must reach unexported index.ts
2522
+ // helpers (generateBlindIndices, generateEmbedding, getLSHHasher,
2523
+ // computeCandidatePool, isDigestBlob, readClaimFromBlob, searchSubgraph,
2524
+ // searchSubgraphBroadened, getSubgraphFactCount, fetchFactById,
2525
+ // ensureInitialized) AND module-level state (authKeyHex, encryptionKey,
2526
+ // userId, subgraphOwner). Lifting these out of index.ts is a high
2527
+ // blast-radius refactor with scanner-trap risk — out of scope for 2.7.
2528
+ //
2529
+ // native-memory.ts (the wiring helper) is pure orchestration and stays
2530
+ // scanner-trivial; the closures stay here where the rest of the plugin's
2531
+ // network surface lives.
2532
+ //
2533
+ // LAZY CONTEXT RESOLUTION:
2534
+ // The paired-account context (authKeyHex / encryptionKey / userId /
2535
+ // subgraphOwner) is NOT resolved synchronously at register() time. It is
2536
+ // populated by `initialize()` on the first tool/hook call via
2537
+ // `ensureInitialized()`. So each closure calls `ensureInitialized(logger)`
2538
+ // internally before touching the module-level state — the same lazy-init
2539
+ // seam the retired totalreclaw_recall tool used to use (then via
2540
+ // `requireFullSetup`).
2541
+ //
2542
+ // If setup is incomplete (no credentials), `ensureInitialized` returns
2543
+ // with `needsSetup=true`; the closures then surface a typed error
2544
+ // (`getMemorySearchManager` will return `{ manager: null, error }` from
2545
+ // the runtime wrapper, which the tools convert into the disabled-result
2546
+ // payload the agent recognizes).
2547
+ //
2548
+ // SCANNER NOTE:
2549
+ // This file (index.ts) is NOT scanner-clean — it is the plugin's network
2550
+ // surface and contains the env+net pair legitimately (centralized via
2551
+ // config.ts reads + relay.ts). The closures here CALL the scanner-clean
2552
+ // subgraph-search / vault-crypto / reranker modules but do not add any
2553
+ // NEW env-harvesting or exfiltration pattern: they read only the
2554
+ // already-resolved module-level state. `check-scanner` was already
2555
+ // non-zero on index.ts before this task (the file legitimately pairs
2556
+ // config reads with network calls); the closures do not change that
2557
+ // posture. The NEW files (native-memory.ts, register-native.test.ts)
2558
+ // are scanner-clean by construction (verified).
2559
+ // ---------------------------------------------------------------------------
2560
+ /**
2561
+ * Build the deps for the native MemoryPluginCapability. Returns the
2562
+ * `recall` / `getById` closures bound to the real subgraph + decrypt +
2563
+ * reranker pipeline, plus optional `quota` / `pinned` prompt-builder inputs
2564
+ * (currently defaulted — see TODO(task 2.7b / H1 gate) below).
2565
+ *
2566
+ * `logger` is threaded in so the closures can call `ensureInitialized` (the
2567
+ * lazy-init seam used by every other tool handler in this file).
2568
+ *
2569
+ * @param logger the OpenClaw plugin logger (forwarded into ensureInitialized)
2570
+ */
2571
+ function buildRecallDeps(logger) {
2572
+ // -------------------------------------------------------------------
2573
+ // recall(): the load-bearing closure. This is the search/decrypt/rerank
2574
+ // pipeline that backs the native memory_search tool via the
2575
+ // TrMemorySearchManager adapter. It replaced the retired totalreclaw_recall
2576
+ // agent tool handler (Phase 3.2) MINUS the tool-level result formatting +
2577
+ // hot-cache bookkeeping (those are tool concerns; the native memory
2578
+ // pipeline only needs the TrFact[]). Returns TrFact[] shaped for the
2579
+ // TrMemorySearchManager adapter (memory-runtime.ts).
2580
+ // -------------------------------------------------------------------
2581
+ const recall = async (query, opts) => {
2582
+ // Lazy-init: this is the first seam the closure hits. If the user
2583
+ // is not paired, ensureInitialized returns with needsSetup=true; we
2584
+ // surface that as an empty result (the before_tool_call gate in
2585
+ // tool-gating.ts normally intercepts memory_search BEFORE this runs
2586
+ // when state != active, but fail-soft here too — a search with no
2587
+ // credentials returning [] is benign; the agent treats empty
2588
+ // results as "no memories found").
2589
+ await ensureInitialized(logger);
2590
+ // Guard: if setup is incomplete OR we're missing the pipeline state,
2591
+ // return []. This is fail-soft: the user sees "no memories" rather
2592
+ // than a thrown error out of the memory_search tool boundary.
2593
+ if (needsSetup || !encryptionKey || !authKeyHex)
2594
+ return [];
2595
+ // subgraphOwner may be null on SA-derivation failure (see initialize()).
2596
+ // The subgraph path requires a non-null owner (Bytes!); if missing,
2597
+ // we cannot run recall — return [].
2598
+ if (isSubgraphMode() && !subgraphOwner)
2599
+ return [];
2600
+ const k = Math.min(opts?.maxResults ?? 8, 20);
2601
+ // 1. Generate word trapdoors (blind indices for the query).
2602
+ const wordTrapdoors = generateBlindIndices(query);
2603
+ // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
2604
+ let queryEmbedding = null;
2605
+ let lshTrapdoors = [];
2606
+ try {
2607
+ queryEmbedding = await generateEmbedding(query, { isQuery: true });
2608
+ const hasher = getLSHHasher(logger);
2609
+ if (hasher && queryEmbedding) {
2610
+ lshTrapdoors = hasher.hash(queryEmbedding);
2611
+ }
2612
+ }
2613
+ catch (err) {
2614
+ const msg = err instanceof Error ? err.message : String(err);
2615
+ logger.warn(`native recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
2616
+ }
2617
+ // 3. Merge word + LSH trapdoors.
2618
+ const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
2619
+ if (allTrapdoors.length === 0)
2620
+ return [];
2621
+ // 4. Build reranker candidates from the decrypted subgraph results.
2622
+ const rerankerCandidates = [];
2623
+ if (isSubgraphMode()) {
2624
+ // --- Subgraph search path (the canonical path for managed installs) ---
2625
+ const factCount = await getSubgraphFactCount(subgraphOwner || userId, authKeyHex);
2626
+ const pool = computeCandidatePool(factCount);
2627
+ let subgraphResults = await searchSubgraph(subgraphOwner || userId, allTrapdoors, pool, authKeyHex);
2628
+ // Broadened search + merge — vocabulary-mismatch safety net (mirrors
2629
+ // the recall tool: ensures "preferences" still matches "prefer").
2630
+ try {
2631
+ const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId, pool, authKeyHex);
2632
+ const existingIds = new Set(subgraphResults.map((r) => r.id));
2633
+ for (const br of broadenedResults) {
2634
+ if (!existingIds.has(br.id))
2635
+ subgraphResults.push(br);
2636
+ }
2637
+ }
2638
+ catch {
2639
+ // best-effort
2640
+ }
2641
+ for (const result of subgraphResults) {
2642
+ try {
2643
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
2644
+ if (isDigestBlob(docJson))
2645
+ continue;
2646
+ const doc = readClaimFromBlob(docJson);
2647
+ let decryptedEmbedding;
2648
+ if (result.encryptedEmbedding) {
2649
+ try {
2650
+ decryptedEmbedding = JSON.parse(decryptFromHex(result.encryptedEmbedding, encryptionKey));
2651
+ }
2652
+ catch {
2653
+ // embedding decryption failed -- proceed without it
2654
+ }
2655
+ }
2656
+ // Dim-mismatch fallback: regenerate the embedding from text so
2657
+ // the reranker's cosine component stays meaningful across model
2658
+ // upgrades. Mirrors the recall tool exactly.
2659
+ if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
2660
+ try {
2661
+ decryptedEmbedding = await generateEmbedding(doc.text);
2662
+ }
2663
+ catch {
2664
+ decryptedEmbedding = undefined;
2665
+ }
2666
+ }
2667
+ rerankerCandidates.push({
2668
+ id: result.id,
2669
+ text: doc.text,
2670
+ embedding: decryptedEmbedding,
2671
+ importance: doc.importance / 10,
2672
+ createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
2673
+ // Retrieval v2 Tier 1 source — surfaced so applySourceWeights
2674
+ // could multiply the final RRF score (left false here to match
2675
+ // the recall tool's current behavior; TODO(task 2.7b): wire
2676
+ // source weighting for the native path at the H1 QA gate).
2677
+ source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
2678
+ });
2679
+ }
2680
+ catch {
2681
+ // Skip candidates we cannot decrypt (corrupted / wrong key).
2682
+ }
2683
+ }
2684
+ }
2685
+ else {
2686
+ // --- Server search path (legacy / self-hosted) ---
2687
+ // The non-subgraph path uses apiClient.search. The native memory
2688
+ // pipeline is intended for managed (subgraph) installs, but we keep
2689
+ // parity with the recall tool so self-hosted users get recall too.
2690
+ if (!apiClient || !userId)
2691
+ return [];
2692
+ const factCount = await getFactCount(logger);
2693
+ const pool = computeCandidatePool(factCount);
2694
+ const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
2695
+ for (const candidate of candidates) {
2696
+ try {
2697
+ const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
2698
+ if (isDigestBlob(docJson))
2699
+ continue;
2700
+ const doc = readClaimFromBlob(docJson);
2701
+ let decryptedEmbedding;
2702
+ if (candidate.encrypted_embedding) {
2703
+ try {
2704
+ decryptedEmbedding = JSON.parse(decryptFromHex(candidate.encrypted_embedding, encryptionKey));
2705
+ }
2706
+ catch {
2707
+ // embedding decryption failed
2708
+ }
2709
+ }
2710
+ if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
2711
+ try {
2712
+ decryptedEmbedding = await generateEmbedding(doc.text);
2713
+ }
2714
+ catch {
2715
+ decryptedEmbedding = undefined;
2716
+ }
2717
+ }
2718
+ rerankerCandidates.push({
2719
+ id: candidate.fact_id,
2720
+ text: doc.text,
2721
+ embedding: decryptedEmbedding,
2722
+ importance: doc.importance / 10,
2723
+ createdAt: typeof candidate.timestamp === 'number'
2724
+ ? candidate.timestamp / 1000
2725
+ : new Date(candidate.timestamp).getTime() / 1000,
2726
+ source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
2727
+ });
2728
+ }
2729
+ catch {
2730
+ // Skip candidates we cannot decrypt.
2731
+ }
2732
+ }
2733
+ }
2734
+ // 5. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
2735
+ const queryIntent = detectQueryIntent(query);
2736
+ const reranked = rerank(query, queryEmbedding ?? [], rerankerCandidates, k, INTENT_WEIGHTS[queryIntent],
2737
+ // applySourceWeights=false — matches the recall tool's current
2738
+ // behavior. TODO(task 2.7b / H1 gate): flip to true for the native
2739
+ // path so Retrieval v2 Tier 1 source weighting takes effect.
2740
+ false);
2741
+ // 6. Map RerankerResult -> TrFact. The score field is rrfScore (the
2742
+ // final fused + weighted score the manager's defensive sort uses).
2743
+ return reranked.map((m) => ({
2744
+ id: m.id,
2745
+ plaintext: m.text,
2746
+ score: m.rrfScore,
2747
+ // pinned is intentionally not surfaced here today — pinned status
2748
+ // lives in claim metadata and there's no clean read-side aggregate
2749
+ // to lift in this task. See getById + pinned TODO below.
2750
+ }));
2751
+ };
2752
+ // -------------------------------------------------------------------
2753
+ // getById(): the load-bearing reverse-path closure. Mirrors the
2754
+ // pin/unpin tool's fetchFactById -> decrypt pattern (the read-back
2755
+ // reverse-path for memory_get). Returns { id, plaintext } or null.
2756
+ // -------------------------------------------------------------------
2757
+ const getById = async (id) => {
2758
+ await ensureInitialized(logger);
2759
+ // Fail-soft on missing setup / encryption key.
2760
+ if (needsSetup || !encryptionKey || !authKeyHex)
2761
+ return null;
2762
+ // The subgraph path is the canonical one; fetchFactById resolves the
2763
+ // fact by UUID and guards against owner mismatch (defense-in-depth
2764
+ // against stale IDs from another user's recall results — see
2765
+ // subgraph-search.ts fetchFactById docstring).
2766
+ if (isSubgraphMode()) {
2767
+ if (!subgraphOwner)
2768
+ return null;
2769
+ try {
2770
+ const result = await fetchFactById(subgraphOwner, id, authKeyHex);
2771
+ if (!result)
2772
+ return null;
2773
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
2774
+ if (isDigestBlob(docJson))
2775
+ return null;
2776
+ const doc = readClaimFromBlob(docJson);
2777
+ return { id, plaintext: doc.text };
2778
+ }
2779
+ catch {
2780
+ return null;
2781
+ }
2782
+ }
2783
+ // Server-path: apiClient doesn't expose a clean get-by-id; fall back
2784
+ // to a recall-style lookup using the id as a single trapdoor. This is
2785
+ // a degenerate path for self-hosted installs and rarely hit (the
2786
+ // native memory pipeline targets managed subgraph installs). Document
2787
+ // rather than gold-plate.
2788
+ // TODO(task 2.7b / H1 gate): wire apiClient get-by-id if/when exposed.
2789
+ if (!apiClient || !userId)
2790
+ return null;
2791
+ try {
2792
+ const candidates = await apiClient.search(userId, [id], 10, authKeyHex);
2793
+ const hit = candidates.find((c) => c.fact_id === id);
2794
+ if (!hit)
2795
+ return null;
2796
+ const docJson = decryptFromHex(hit.encrypted_blob, encryptionKey);
2797
+ if (isDigestBlob(docJson))
2798
+ return null;
2799
+ const doc = readClaimFromBlob(docJson);
2800
+ return { id, plaintext: doc.text };
2801
+ }
2802
+ catch {
2803
+ return null;
2804
+ }
2805
+ };
2806
+ // -------------------------------------------------------------------
2807
+ // quota + pinned: prompt-builder inputs. These drive the warning /
2808
+ // pinned-facts blocks in buildPromptSection (memory-runtime.ts).
2809
+ //
2810
+ // TODO(task 2.7b / H1 QA gate): bind these to the real paired-account
2811
+ // state. The hooks to lift are:
2812
+ // - quota: readBillingCache() in billing-cache.ts exposes
2813
+ // { free_writes_used, free_writes_limit } — when used/limit > 0.8
2814
+ // pass { usedPct }, and on a recently-observed 403 pass { denied }.
2815
+ // The billing cache is refreshed by the trajectory-poller after
2816
+ // each capture attempt; today we default to undefined so no
2817
+ // warning fires (fail-quiet — better than a false warning).
2818
+ // - pinned: there is no clean read-side `fetchPinnedFacts(owner)`
2819
+ // aggregate today. pin.ts writes pinned status into claim metadata;
2820
+ // a pinned-facts read would need either (a) a subgraph query
2821
+ // filtering on the pinned status, or (b) reuse of the hot-cache
2822
+ // pinned list. Both are extraction work — out of scope for 2.7.
2823
+ // Default to [] (no pinned block emitted).
2824
+ //
2825
+ // Returning undefined / [] here is the documented correct default. The
2826
+ // wiring helper accepts a deps object without quota/pinned, and the
2827
+ // prompt builder emits no warning / no pinned block in that case.
2828
+ // -------------------------------------------------------------------
2829
+ const quota = undefined;
2830
+ const pinned = undefined;
2831
+ return { recall, getById, quota, pinned };
2832
+ }
2833
+ // ---------------------------------------------------------------------------
2474
2834
  // Plugin definition
2475
2835
  // ---------------------------------------------------------------------------
2476
2836
  const plugin = {
@@ -2540,67 +2900,7 @@ const plugin = {
2540
2900
  },
2541
2901
  },
2542
2902
  register(api) {
2543
- // ---------------------------------------------------------------
2544
- // 3.3.2-rc.1 (issue #186) — load manifest instrumentation
2545
- // ---------------------------------------------------------------
2546
- //
2547
- // Capture every `api.registerTool({name, ...})` call so we can write
2548
- // a `.loaded.json` manifest at the end of register(). Wrap the body
2549
- // in try/catch so a register-time throw produces `.error.json` for
2550
- // agent-side filesystem verification (the CLI hangs in some Docker
2551
- // setups — issue #182 — so the manifest is the canonical "did the
2552
- // plugin load?" probe).
2553
- //
2554
- // Implementation: we intercept the api.registerTool method by
2555
- // wrapping it on the api object passed in. The wrapper inspects the
2556
- // `name` field (every TR registerTool call sets it) and forwards
2557
- // verbatim. NO behavior change to the SDK call — the original method
2558
- // is invoked with original args and `this` binding.
2559
- //
2560
- // Synchronous writes ONLY (see writePluginManifest doc): the SDK
2561
- // freezes plugin registries the moment register() returns; an async
2562
- // write would race that freeze.
2563
- const _registeredToolNames = [];
2564
- const _originalRegisterTool = api.registerTool.bind(api);
2565
- // 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
2566
- // The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
2567
- // before they reach the agent's session toolset. registerTool() calls
2568
- // succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
2569
- // But tool calls never reach execute() — the pipeline discards them before
2570
- // the agent's toolset is built.
2571
- //
2572
- // Strategy: keep all registerTool() calls intact so the plugin loader can
2573
- // verify the contracts.tools declaration and load the plugin (hooks fire).
2574
- // The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
2575
- // path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
2576
- // are dead-letter but hooks (before_agent_start, agent_end, message_received,
2577
- // before_reset) still fire via the unbroken hook code path.
2578
- //
2579
- // NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
2580
- // contracts.tools declaration against registered tools at load time and
2581
- // drops the plugin (unloads it) if no tools match. Confirmed empirically:
2582
- // no-op'ing registerTool causes the gateway to log "4 plugins" instead of
2583
- // "5 plugins" after restart (plugin excluded from active set).
2584
- //
2585
- // TODO: when OC ships a fix for issue #223, restore tool-call routing
2586
- // and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
2587
- // can stay as a convenience CLI regardless.
2588
- api.registerTool = (tool, opts) => {
2589
- try {
2590
- const t = tool;
2591
- if (t && typeof t === 'object' && typeof t.name === 'string' && t.name.length > 0) {
2592
- _registeredToolNames.push(t.name);
2593
- }
2594
- }
2595
- catch {
2596
- // Manifest is diagnostic; never let bookkeeping break tool registration.
2597
- }
2598
- _originalRegisterTool(tool, opts);
2599
- };
2600
- // Lazily resolved inside the try below — needed by both the manifest
2601
- // write and the error path. `dist/` after build, package root in tests.
2602
- let _pluginDirForManifest = null;
2603
- // NOTE: the body of register() below is intentionally NOT re-indented
2903
+ // NOTE: the body of register() below is intentionally NOT re-indent
2604
2904
  // under this `try` block — re-indenting would touch every line in a
2605
2905
  // 3,500-line function and obscure the actual hotfix diff. The closing
2606
2906
  // `} catch (registerErr: unknown) { ... }` is at the very end of
@@ -2610,9 +2910,11 @@ const plugin = {
2610
2910
  // RC-build detection (3.3.1-rc.3)
2611
2911
  // ---------------------------------------------------------------
2612
2912
  //
2613
- // `isRcBuild` reads the plugin's own version string. When true, the
2614
- // `totalreclaw_report_qa_bug` tool is registered at the end of this
2615
- // function stable builds never see it. The version is resolved via
2913
+ // `isRcBuild` reads the plugin's own version string. The resulting
2914
+ // `rcMode` flag is currently logged but has no gating effect after
2915
+ // Task 3.2 retired the RC-only `totalreclaw_report_qa_bug` agent tool
2916
+ // (the only former consumer). The flag is retained for the log line and
2917
+ // any future RC-gated diagnostic surface. The version is resolved via
2616
2918
  // `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
2617
2919
  let rcMode = false;
2618
2920
  // Plugin version resolved from package.json once at register time. Reused
@@ -2630,7 +2932,6 @@ const plugin = {
2630
2932
  // `require('node:url')` — undefined under bare-ESM Node, broke the
2631
2933
  // before_agent_start hook in the published rc.20 bundle (issue #124).
2632
2934
  const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
2633
- _pluginDirForManifest = pluginDir; // captured for #186 .loaded.json/.error.json
2634
2935
  pluginVersion = readPluginVersion(pluginDir);
2635
2936
  rcMode = isRcBuild(pluginVersion);
2636
2937
  if (rcMode) {
@@ -2684,10 +2985,10 @@ const plugin = {
2684
2985
  }
2685
2986
  // 3.3.3-rc.1 (issue #187 — ONNX decouple): kick off a non-blocking
2686
2987
  // bundle prefetch so the ~700 MB embedder tarball starts streaming
2687
- // as soon as the gateway boots, BEFORE the user completes
2688
- // `totalreclaw_pair`. Decouples the model download from the
2689
- // pair-completion gate the previous flow imposed via
2690
- // `requireFullSetup()` -> first `generateEmbedding()` call.
2988
+ // as soon as the gateway boots, BEFORE the user completes pairing
2989
+ // (`tr pair` / the `/plugin/totalreclaw/pair/*` HTTP route). Decouples
2990
+ // the model download from the pair-completion gate the previous flow
2991
+ // imposed via `requireFullSetup()` -> first `generateEmbedding()` call.
2691
2992
  // Fire-and-forget — never awaits, never throws on failure (the next
2692
2993
  // `generateEmbedding()` call retries via the same idempotent path).
2693
2994
  // Disabled when `TOTALRECLAW_DISABLE_EMBEDDER_PREFETCH=1` (CI / tests
@@ -2736,14 +3037,16 @@ const plugin = {
2736
3037
  // Best-effort. Helper logs internally and never throws.
2737
3038
  }
2738
3039
  // 3.3.9-rc.2 (issues #225 + #226): auto-patch openclaw.json for
2739
- // OpenClaw 2026.5.x. Two required config keys were not auto-applied
2740
- // by `openclaw plugins install` in 2026.5.x:
3040
+ // OpenClaw 2026.5.x. Required config keys not auto-applied by
3041
+ // `openclaw plugins install` in 2026.5.x:
2741
3042
  //
2742
- // 1. plugins.slots.memory = "totalreclaw"
2743
- // OpenClaw 2026.5.x introduced memory-slot exclusivitya
2744
- // memory-kind plugin MUST explicitly claim the slot or it is
2745
- // silently disabled (no error shown; `openclaw plugins inspect`
2746
- // shows "memory slot set to memory-core"). #225.
3043
+ // NOTE (rc.20, #402): patchOpenClawConfig now applies only the two
3044
+ // keys below. Retired: the memory slot (plugins.slots.memoryOpenClaw
3045
+ // 2026.6.8 claims it natively on install/enable), the installs
3046
+ // self-heal (plugins.installs native install owns it; a fabricated
3047
+ // record fails the host's schema validation), and the plugins.allow
3048
+ // self-append + plugins.bundledDiscovery="compat" pair (native install
3049
+ // manages the allowlist).
2747
3050
  //
2748
3051
  // 2. plugins.entries.totalreclaw.hooks.allowConversationAccess = true
2749
3052
  // Non-bundled plugins in 2026.5.x require this flag to receive
@@ -2762,9 +3065,9 @@ const plugin = {
2762
3065
  // openclaw.json at startup, not dynamically). We emit a warn so
2763
3066
  // the user and ops scripts know to trigger a restart.
2764
3067
  try {
2765
- // 3.3.12-rc.3: pass pluginVersion so Fix #6 can self-heal a
2766
- // stripped `plugins.installs.totalreclaw` record (and unblock
2767
- // Fix #1 which gates on installs being present).
3068
+ // pluginVersion is still passed for signature stability; as of rc.20
3069
+ // (#402) patchOpenClawConfig no longer consumes it (the Fix #6 installs
3070
+ // self-heal it fed was retired).
2768
3071
  const patchResult = patchOpenClawConfig(undefined, pluginVersion ?? undefined);
2769
3072
  if (patchResult === 'patched') {
2770
3073
  // 3.3.12-rc.6 (auto-QA finding 2026-05-09): previously we only
@@ -2798,9 +3101,7 @@ const plugin = {
2798
3101
  // (registered ~400 lines below) under the same scanner-safe
2799
3102
  // pattern.
2800
3103
  api.logger.warn('TotalReclaw: updated openclaw.json with required 2026.5.x keys ' +
2801
- '(plugins.slots.memory + hooks.allowConversationAccess + ' +
2802
- 'channels.telegram.streaming.mode + plugins.bundledDiscovery + ' +
2803
- 'plugins.allow + plugins.installs.totalreclaw self-heal). ' +
3104
+ '(hooks.allowConversationAccess + channels.telegram.streaming.mode). ' +
2804
3105
  'Auto-restarting gateway via SIGUSR1 to apply.');
2805
3106
  setTimeout(() => {
2806
3107
  try {
@@ -2816,9 +3117,10 @@ const plugin = {
2816
3117
  }
2817
3118
  else if (patchResult === 'error') {
2818
3119
  api.logger.warn('TotalReclaw: failed to auto-patch openclaw.json for OpenClaw 2026.5.x ' +
2819
- 'compatibility. If memory hooks are silently disabled, add these keys ' +
2820
- 'manually: plugins.slots.memory="totalreclaw" and ' +
2821
- 'plugins.entries.totalreclaw.hooks.allowConversationAccess=true.');
3120
+ 'compatibility. If memory hooks are silently disabled, add this key ' +
3121
+ 'manually: plugins.entries.totalreclaw.hooks.allowConversationAccess=true. ' +
3122
+ '(The memory slot is set by OpenClaw itself on plugin install/enable; ' +
3123
+ 'if the slot is wrong, run: openclaw plugins enable totalreclaw)');
2822
3124
  }
2823
3125
  // 'unchanged' and 'skipped' are silent — no log needed.
2824
3126
  }
@@ -2844,71 +3146,6 @@ const plugin = {
2844
3146
  }
2845
3147
  }
2846
3148
  // ---------------------------------------------------------------
2847
- // 3.3.13 — auto-pair-on-load (Pop-OS hallucination fix)
2848
- // ---------------------------------------------------------------
2849
- //
2850
- // If credentials.json is missing, open a relay pair session NOW and
2851
- // write URL + PIN + sid to ~/.totalreclaw/.pair-pending.json. The
2852
- // before_agent_start hook installed below reads that file and tells
2853
- // the agent the EXACT values to surface — the agent no longer guesses.
2854
- //
2855
- // Fire-and-forget so a relay outage never blocks plugin load. The
2856
- // sentinel write happens BEFORE the hook is registered, but the WS
2857
- // listener and credentials.json write run in the background after
2858
- // register() returns.
2859
- void (async () => {
2860
- try {
2861
- const result = await maybeStartAutoPair({
2862
- credentialsPath: CREDENTIALS_PATH,
2863
- pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
2864
- onboardingStatePath: CONFIG.onboardingStatePath,
2865
- relayBaseUrl: CONFIG.pairRelayUrl,
2866
- pluginVersion: pluginVersion ?? '3.3.0',
2867
- logger: api.logger,
2868
- });
2869
- switch (result.status) {
2870
- case 'creds_exist':
2871
- // No-op — happy path; user is already set up.
2872
- break;
2873
- case 'pending_reused':
2874
- api.logger.info('TotalReclaw: setup pending. Reusing existing pair URL at ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.');
2875
- break;
2876
- case 'started':
2877
- api.logger.info('TotalReclaw: setup pending. Pair URL written to ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.');
2878
- break;
2879
- case 'failed':
2880
- api.logger.warn(`TotalReclaw: setup pending. Auto-pair failed (${result.error}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`);
2881
- break;
2882
- }
2883
- }
2884
- catch (err) {
2885
- const msg = err instanceof Error ? err.message : String(err);
2886
- api.logger.warn(`TotalReclaw: setup pending. Auto-pair failed (${msg}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`);
2887
- }
2888
- })();
2889
- // Install the before_agent_start hook that reads the sentinel and
2890
- // injects URL + PIN into the agent's context verbatim. Registered
2891
- // here so it lives alongside the other hook registrations in
2892
- // register(); the hook body itself never blocks on plugin load.
2893
- try {
2894
- installPairPendingHook(api, {
2895
- credentialsPath: CREDENTIALS_PATH,
2896
- pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
2897
- autoPairDepsFactory: () => ({
2898
- credentialsPath: CREDENTIALS_PATH,
2899
- pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
2900
- onboardingStatePath: CONFIG.onboardingStatePath,
2901
- relayBaseUrl: CONFIG.pairRelayUrl,
2902
- pluginVersion: pluginVersion ?? '3.3.0',
2903
- logger: api.logger,
2904
- }),
2905
- });
2906
- }
2907
- catch (err) {
2908
- const msg = err instanceof Error ? err.message : String(err);
2909
- api.logger.warn(`TotalReclaw: failed to register pair-pending-injection hook: ${msg}`);
2910
- }
2911
- // ---------------------------------------------------------------
2912
3149
  // LLM client initialization (auto-detect provider from OpenClaw config)
2913
3150
  // ---------------------------------------------------------------
2914
3151
  //
@@ -3039,6 +3276,242 @@ const plugin = {
3039
3276
  });
3040
3277
  },
3041
3278
  });
3279
+ // ---------------------------------------------------------------
3280
+ // 3.3.13 — `openclaw totalreclaw import ...` + `upgrade`
3281
+ //
3282
+ // Phase 3.2 retired the totalreclaw_import_from / import_status /
3283
+ // import_abort / upgrade agent tools (recall is native; the rest
3284
+ // became CLI/HTTP surfaces). The handlers stayed in this file
3285
+ // (auto-resume still calls handlePluginImportFrom on gateway
3286
+ // restart) but had NO user-facing entry point — users could not
3287
+ // START a new import, only auto-resume worked. This wiring closes
3288
+ // that gap.
3289
+ //
3290
+ // Why this lives on the `openclaw totalreclaw` subcommand chain
3291
+ // (NOT the standalone `tr` CLI binary): the import handler reaches
3292
+ // module-level state (authKeyHex / encryptionKey / subgraphOwner)
3293
+ // populated by initialize(), plus storeExtractedFacts +
3294
+ // extractFacts + runSmartImportPipeline. The `tr` binary
3295
+ // (tr-cli.ts) is a standalone Node script that does NOT import
3296
+ // the plugin runtime; importing index.ts from it would pull in
3297
+ // the entire gateway runtime. The registerCli subcommand runs
3298
+ // INSIDE the gateway process, so the handlers are directly in
3299
+ // scope — same pattern as `onboard` / `status` / `pair`.
3300
+ //
3301
+ // JSON output: every subcommand accepts --json and emits a single
3302
+ // machine-parseable JSON line on stdout (agent-driven use). Plain
3303
+ // text is for direct user CLI use.
3304
+ //
3305
+ // rc.20 (#402): the import/upgrade wiring below referenced a bare
3306
+ // `tr` that was never declared in THIS callback scope —
3307
+ // registerOnboardingCli and registerPairCli each declare their own
3308
+ // LOCAL `tr`, invisible here. That undeclared reference threw
3309
+ // `ReferenceError: tr is not defined` the moment OpenClaw ran the
3310
+ // callback, killing EVERY `openclaw totalreclaw <sub>` command
3311
+ // (dead since the 3.3.13 import/upgrade restoration; shipped in
3312
+ // rc.19 + rc.20). The build is `tsc --noCheck`, so the type checker
3313
+ // never caught it. Resolve the command group the same way
3314
+ // registerPairCli does — registerOnboardingCli always created it, so
3315
+ // this find() succeeds; the guard is belt-and-braces.
3316
+ const tr = program.commands.find((c) => c.name() === 'totalreclaw');
3317
+ if (!tr) {
3318
+ api.logger.warn('TotalReclaw: `totalreclaw` CLI group not found after onboarding/pair registration — ' +
3319
+ 'skipping import/upgrade wiring. `openclaw totalreclaw import`/`upgrade` will be unavailable.');
3320
+ return;
3321
+ }
3322
+ const importCmd = tr.command('import')
3323
+ .description('Import memories from another tool (Mem0, MCP Memory, ChatGPT, Claude, Gemini). ' +
3324
+ 'Subcommands: `import status`, `import abort`.');
3325
+ importCmd
3326
+ .command('from', { isDefault: true })
3327
+ .description('Start an import from a source tool. Conversation sources (ChatGPT/Claude/Gemini) ' +
3328
+ 'run in the background; poll with `import status`. Pre-structured sources (Mem0/MCP) ' +
3329
+ 'store synchronously.')
3330
+ .argument('<source>', 'mem0 | mcp-memory | chatgpt | claude | gemini')
3331
+ .option('--file <path>', 'Path to the source file on disk')
3332
+ .option('--content <text>', 'Inline source content (JSON/JSONL/CSV/text)')
3333
+ .option('--api-key <key>', 'API key for the source (used once, never stored)')
3334
+ .option('--source-user-id <id>', 'User/agent ID in the source system')
3335
+ .option('--api-url <url>', 'API base URL override (self-hosted instances)')
3336
+ .option('--dry-run', 'Parse + report without storing')
3337
+ .option('--resume <importId>', 'Resume a previously-started import by id')
3338
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3339
+ .action(async (source, opts) => {
3340
+ try {
3341
+ await requireFullSetup(api.logger);
3342
+ const result = await handlePluginImportFrom({
3343
+ source,
3344
+ file_path: opts.file,
3345
+ content: opts.content,
3346
+ api_key: opts.apiKey,
3347
+ source_user_id: opts.sourceUserId,
3348
+ api_url: opts.apiUrl,
3349
+ dry_run: opts.dryRun,
3350
+ resume_id: opts.resume,
3351
+ disclosure_confirmed: true,
3352
+ }, api.logger);
3353
+ if (opts.json) {
3354
+ process.stdout.write(JSON.stringify(result) + '\n');
3355
+ }
3356
+ else {
3357
+ // Human-readable summary. The handler already returns a
3358
+ // `message` for chunked (background) imports; for direct
3359
+ // stores + dry runs, synthesize a short summary.
3360
+ if (result.dry_run) {
3361
+ const chunks = result.total_chunks;
3362
+ if (chunks !== undefined) {
3363
+ process.stdout.write(`Dry run: ~${result.estimated_facts} facts from ${chunks} chunks ` +
3364
+ `(~${result.estimated_minutes} min). Confirm without --dry-run to start.\n`);
3365
+ }
3366
+ else {
3367
+ process.stdout.write(`Dry run: found ${result.total_found} facts. Confirm without --dry-run to import.\n`);
3368
+ }
3369
+ }
3370
+ else if (result.import_id && result.status === 'running') {
3371
+ process.stdout.write(`${result.message}\nImport id: ${result.import_id}\n`);
3372
+ }
3373
+ else {
3374
+ const stored = result.imported;
3375
+ const total = result.total_found;
3376
+ process.stdout.write(`Imported ${stored ?? 0}/${total ?? stored ?? 0} facts from ${source}.\n`);
3377
+ }
3378
+ }
3379
+ }
3380
+ catch (err) {
3381
+ const message = err instanceof Error ? err.message : String(err);
3382
+ if (opts.json) {
3383
+ process.stdout.write(JSON.stringify({ success: false, error: message }) + '\n');
3384
+ }
3385
+ else {
3386
+ process.stderr.write(`import failed: ${message}\n`);
3387
+ }
3388
+ process.exit(1);
3389
+ }
3390
+ });
3391
+ importCmd
3392
+ .command('status')
3393
+ .description('Check progress of a background import. Omit --id for the most recent active import.')
3394
+ .option('--id <importId>', 'Import id (from `import from`). Omit for most-recent active.')
3395
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3396
+ .action(async (opts) => {
3397
+ try {
3398
+ await requireFullSetup(api.logger);
3399
+ const result = await handleImportStatus({ import_id: opts.id }, api.logger);
3400
+ if (opts.json) {
3401
+ process.stdout.write(JSON.stringify(result) + '\n');
3402
+ }
3403
+ else {
3404
+ const status = result.status;
3405
+ const stored = result.facts_stored;
3406
+ const batchDone = result.batch_done;
3407
+ const batchTotal = result.batch_total;
3408
+ if (status === 'no_active_import') {
3409
+ process.stdout.write('No active import. Start one with `openclaw totalreclaw import from <source>`.\n');
3410
+ }
3411
+ else if (status === 'running') {
3412
+ process.stdout.write(`Import ${result.import_id}: running — ${stored} facts stored, ` +
3413
+ `batch ${batchDone}/${batchTotal}` +
3414
+ (result.completion_iso ? `, ETA ${result.completion_iso}` : '') + '.\n');
3415
+ }
3416
+ else {
3417
+ process.stdout.write(`Import ${result.import_id}: ${status} — ${stored ?? 0} facts stored.\n`);
3418
+ }
3419
+ }
3420
+ }
3421
+ catch (err) {
3422
+ const message = err instanceof Error ? err.message : String(err);
3423
+ if (opts.json) {
3424
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
3425
+ }
3426
+ else {
3427
+ process.stderr.write(`import status failed: ${message}\n`);
3428
+ }
3429
+ process.exit(1);
3430
+ }
3431
+ });
3432
+ importCmd
3433
+ .command('abort')
3434
+ .description('Cancel a running background import. Already-stored facts are kept.')
3435
+ .argument('<importId>', 'Import id to abort (from `import from` or `import status`)')
3436
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3437
+ .action(async (importId, opts) => {
3438
+ try {
3439
+ await requireFullSetup(api.logger);
3440
+ const result = await handleImportAbort({ import_id: importId }, api.logger);
3441
+ if (opts.json) {
3442
+ process.stdout.write(JSON.stringify(result) + '\n');
3443
+ }
3444
+ else {
3445
+ if (result.aborted) {
3446
+ process.stdout.write(`Import ${importId}: abort requested. ${result.facts_already_stored ?? 0} facts already stored (kept).\n`);
3447
+ }
3448
+ else {
3449
+ process.stdout.write(`Import ${importId}: ${result.error ?? 'abort failed'}\n`);
3450
+ }
3451
+ }
3452
+ }
3453
+ catch (err) {
3454
+ const message = err instanceof Error ? err.message : String(err);
3455
+ if (opts.json) {
3456
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
3457
+ }
3458
+ else {
3459
+ process.stderr.write(`import abort failed: ${message}\n`);
3460
+ }
3461
+ process.exit(1);
3462
+ }
3463
+ });
3464
+ // `openclaw totalreclaw upgrade` — Stripe checkout URL for Pro.
3465
+ // Restores the retired totalreclaw_upgrade agent tool (cd21176).
3466
+ // Self-contained: POST /v1/billing/checkout → checkout_url.
3467
+ tr.command('upgrade')
3468
+ .description('Get a Stripe checkout URL to upgrade to TotalReclaw Pro (unlimited memories on Gnosis mainnet).')
3469
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3470
+ .action(async (opts) => {
3471
+ try {
3472
+ await requireFullSetup(api.logger);
3473
+ if (!authKeyHex) {
3474
+ throw new Error('Auth credentials are not available. Pair first (`openclaw totalreclaw pair`).');
3475
+ }
3476
+ const walletAddr = subgraphOwner || userId || '';
3477
+ if (!walletAddr) {
3478
+ throw new Error('Wallet address not available. Ensure the plugin is fully initialized.');
3479
+ }
3480
+ const response = await fetch(`${CONFIG.serverUrl}/v1/billing/checkout`, {
3481
+ method: 'POST',
3482
+ headers: buildRelayHeaders({
3483
+ 'Authorization': `Bearer ${authKeyHex}`,
3484
+ 'Content-Type': 'application/json',
3485
+ }),
3486
+ body: JSON.stringify({ wallet_address: walletAddr, tier: 'pro' }),
3487
+ });
3488
+ if (!response.ok) {
3489
+ const body = await response.text().catch(() => '');
3490
+ throw new Error(`checkout session failed (HTTP ${response.status}): ${body || response.statusText}`);
3491
+ }
3492
+ const data = await response.json();
3493
+ if (!data.checkout_url) {
3494
+ throw new Error('no checkout URL returned by the relay');
3495
+ }
3496
+ if (opts.json) {
3497
+ process.stdout.write(JSON.stringify({ checkout_url: data.checkout_url }) + '\n');
3498
+ }
3499
+ else {
3500
+ process.stdout.write(`Open this URL to upgrade to Pro: ${data.checkout_url}\n`);
3501
+ }
3502
+ }
3503
+ catch (err) {
3504
+ const message = err instanceof Error ? err.message : String(err);
3505
+ api.logger.error(`openclaw totalreclaw upgrade failed: ${message}`);
3506
+ if (opts.json) {
3507
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
3508
+ }
3509
+ else {
3510
+ process.stderr.write(`upgrade failed: ${humanizeError(message)}\n`);
3511
+ }
3512
+ process.exit(1);
3513
+ }
3514
+ });
3042
3515
  }, { commands: ['totalreclaw'] });
3043
3516
  }
3044
3517
  else {
@@ -3074,6 +3547,17 @@ const plugin = {
3074
3547
  sessionsPath: CONFIG.pairSessionsPath,
3075
3548
  apiBase: '/plugin/totalreclaw/pair',
3076
3549
  logger: api.logger,
3550
+ // 3.3.14 — wire the relay URL so buildPairRoutes exposes the
3551
+ // in-process `/pair/init` route. The gateway process opens the
3552
+ // relay WebSocket directly (via openRemotePairSession from
3553
+ // pair-remote-client.ts), eliminating the 30s-subprocess-kill
3554
+ // 502 that the CLI path (tr pair) hit when OpenClaw's shell
3555
+ // tool killed the subprocess mid-pair. relayBaseUrl is sourced
3556
+ // from CONFIG.pairRelayUrl (config.ts reads it from the env
3557
+ // once, centrally) — never read from the environment inside
3558
+ // pair-http.ts (scanner-surface rule).
3559
+ relayBaseUrl: CONFIG.pairRelayUrl,
3560
+ initPairMode: 'either',
3077
3561
  validateMnemonic: (p) => validateMnemonic(p, wordlist),
3078
3562
  completePairing: async ({ mnemonic }) => {
3079
3563
  // Write credentials.json + flip state to 'active' via
@@ -3117,11 +3601,6 @@ const plugin = {
3117
3601
  credentialsCreatedAt: new Date().toISOString(),
3118
3602
  version: pluginVersion ?? '3.3.0',
3119
3603
  });
3120
- // 3.3.13 — sentinel cleanup. credentials.json is now the source
3121
- // of truth; the auto-pair-on-load .pair-pending.json sentinel
3122
- // must be removed so the before_agent_start hook stops surfacing
3123
- // the URL + PIN on subsequent turns.
3124
- deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
3125
3604
  return { state: 'active' };
3126
3605
  },
3127
3606
  });
@@ -3139,7 +3618,20 @@ const plugin = {
3139
3618
  api.registerHttpRoute({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
3140
3619
  api.registerHttpRoute({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
3141
3620
  api.registerHttpRoute({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
3142
- api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously');
3621
+ // 3.3.14 in-process pair trigger. The bundle exposes initPath +
3622
+ // handlers.init ONLY when relayBaseUrl is wired (always true here,
3623
+ // since CONFIG.pairRelayUrl has a built-in default). Registered
3624
+ // with auth: 'plugin' (same as the other pair routes) so the
3625
+ // agent's localhost curl reaches it without a gateway bearer
3626
+ // token. The route opens the relay WS in the gateway process →
3627
+ // survives shell-tool timeouts, retries, SIGUSR1 reloads.
3628
+ if (bundle.initPath && bundle.handlers.init) {
3629
+ api.registerHttpRoute({ path: bundle.initPath, handler: bundle.handlers.init, auth: 'plugin' });
3630
+ api.logger.info('TotalReclaw: registered 5 QR-pairing HTTP routes synchronously (incl. in-process /pair/init)');
3631
+ }
3632
+ else {
3633
+ api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously (in-process /pair/init not wired — no relay URL)');
3634
+ }
3143
3635
  }
3144
3636
  else {
3145
3637
  api.logger.warn('api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
@@ -3221,47 +3713,17 @@ const plugin = {
3221
3713
  };
3222
3714
  }
3223
3715
  if (sub === 'diag') {
3224
- // 3.3.7-rc.1 (issue #216) — diagnostic surface for the
3225
- // tool-binding-on-restart bug. Reports whether the
3226
- // plugin's register() ran in this process (boot count +
3227
- // pid + version + tool count). Non-secret: only public
3228
- // package metadata. The actual filesystem read lives in
3229
- // fs-helpers.readPluginLoadedManifest() so this file
3230
- // stays scanner-clean (whole-file rule disallows fs.read*
3231
- // co-located with `fetch` / `post` markers).
3232
- //
3233
- // Usage: `/totalreclaw diag` from chat OR `cat
3234
- // <pluginDir>/.loaded.json` from the host shell. Both
3235
- // surfaces should agree; if chat says boot=N but the
3236
- // file says boot=N+1, the chat session is stale and a
3237
- // /totalreclaw-restart is warranted.
3238
- try {
3239
- const m = _pluginDirForManifest
3240
- ? readPluginLoadedManifest(_pluginDirForManifest)
3241
- : null;
3242
- if (!m) {
3243
- return {
3244
- text: 'TotalReclaw diag:\n' +
3245
- ` pid=${process.pid}\n` +
3246
- ` version=${pluginVersion ?? 'unknown'}\n` +
3247
- ' loaded-manifest: NOT FOUND (register() may have failed — check .error.json)',
3248
- };
3249
- }
3250
- const stalePid = typeof m.pid === 'number' && m.pid !== process.pid;
3251
- return {
3252
- text: 'TotalReclaw diag:\n' +
3253
- ` current pid=${process.pid}\n` +
3254
- ` manifest pid=${m.pid ?? '?'}${stalePid ? ' (STALE — file from prior boot, register() did NOT run in this process)' : ''}\n` +
3255
- ` version=${m.version ?? 'unknown'}\n` +
3256
- ` boot count=${m.bootCount ?? '?'}\n` +
3257
- ` boot at=${m.bootAt ?? '?'}\n` +
3258
- ` tools registered=${m.tools?.length ?? 0}`,
3259
- };
3260
- }
3261
- catch (err) {
3262
- const msg = err instanceof Error ? err.message : String(err);
3263
- return { text: `TotalReclaw diag: error reading manifest (${msg})` };
3264
- }
3716
+ // Diagnostic surface. The 3.3.7-rc.1 `.loaded.json` manifest
3717
+ // (boot count + pid + tool count) was retired in Phase 3.4 —
3718
+ // the writer was removed in 3.1 and the reader had nothing
3719
+ // current to read. `/totalreclaw diag` now reports pid + the
3720
+ // in-memory plugin version only. For richer boot history,
3721
+ // consult the gateway logs.
3722
+ return {
3723
+ text: 'TotalReclaw diag:\n' +
3724
+ ` pid=${process.pid}\n` +
3725
+ ` version=${pluginVersion ?? 'unknown'}\n`,
3726
+ };
3265
3727
  }
3266
3728
  return {
3267
3729
  text: 'TotalReclaw slash commands:\n' +
@@ -3407,2297 +3869,20 @@ const plugin = {
3407
3869
  return undefined;
3408
3870
  }, { priority: 5 });
3409
3871
  // ---------------------------------------------------------------
3410
- // Tool: totalreclaw_remember
3411
- // ---------------------------------------------------------------
3412
- api.registerTool({
3413
- name: 'totalreclaw_remember',
3414
- label: 'Remember',
3415
- description: 'Store a memory in the encrypted vault. Use this when the user shares important information worth remembering.',
3416
- parameters: {
3417
- type: 'object',
3418
- properties: {
3419
- text: {
3420
- type: 'string',
3421
- description: 'The memory text to store',
3422
- },
3423
- type: {
3424
- type: 'string',
3425
- // Dedup the merged enum. `preference` and `summary` appear in
3426
- // BOTH v1 (VALID_MEMORY_TYPES) and legacy v0 (LEGACY_V0_MEMORY_TYPES),
3427
- // so the naive spread produces duplicate items at ## 5 and 12
3428
- // (QA failure on 3.0.7-rc.1: ajv rejects schema with "items ##
3429
- // 5 and 12 are identical"). `new Set(...)` drops dupes while
3430
- // preserving insertion order so v1 tokens appear first in the
3431
- // enum — agents default to picking one of those.
3432
- enum: Array.from(new Set([...VALID_MEMORY_TYPES, ...LEGACY_V0_MEMORY_TYPES])),
3433
- description: 'Memory Taxonomy v1 type: claim, preference, directive, commitment, episode, summary. ' +
3434
- 'Use "claim" for factual assertions and decisions (populate `reasoning` with the why clause). ' +
3435
- 'Use "directive" for imperative rules ("always X", "never Y"), "commitment" for future intent, ' +
3436
- 'and "episode" for notable events. Legacy v0 tokens (fact, decision, episodic, goal, context, ' +
3437
- 'rule) are silently coerced to their v1 equivalents. Default: claim.',
3438
- },
3439
- source: {
3440
- type: 'string',
3441
- enum: [...VALID_MEMORY_SOURCES],
3442
- description: 'v1 provenance tag. "user" = user explicitly stated it, "user-inferred" = inferred from user ' +
3443
- 'signals, "assistant" = assistant-authored (downgrade unless user affirmed), "external" / ' +
3444
- '"derived" = rare. Explicit remembers default to "user".',
3445
- },
3446
- scope: {
3447
- type: 'string',
3448
- enum: [...VALID_MEMORY_SCOPES],
3449
- description: 'v1 life-domain scope: work, personal, health, family, creative, finance, misc, unspecified. ' +
3450
- 'Default: unspecified.',
3451
- },
3452
- reasoning: {
3453
- type: 'string',
3454
- description: 'For type=claim expressing a decision, the WHY clause ("because Y"). Max 256 chars. ' +
3455
- 'Omit for non-decision claims.',
3456
- maxLength: 256,
3457
- },
3458
- importance: {
3459
- type: 'number',
3460
- minimum: 1,
3461
- maximum: 10,
3462
- description: 'Importance score 1-10 (default: 8 for explicit remember)',
3463
- },
3464
- entities: {
3465
- type: 'array',
3466
- description: 'Named entities this memory is about (people, projects, tools, companies, concepts, places). ' +
3467
- 'Supplying entities enables Phase 2 contradiction detection against existing facts about the same entity. ' +
3468
- 'Omit if unclear — a best-effort fallback will still store the memory.',
3469
- items: {
3470
- type: 'object',
3471
- properties: {
3472
- name: { type: 'string' },
3473
- type: {
3474
- type: 'string',
3475
- enum: ['person', 'project', 'tool', 'company', 'concept', 'place'],
3476
- },
3477
- role: { type: 'string' },
3478
- },
3479
- required: ['name', 'type'],
3480
- additionalProperties: false,
3481
- },
3482
- },
3483
- },
3484
- required: ['text'],
3485
- additionalProperties: false,
3486
- },
3487
- async execute(_toolCallId, params) {
3488
- try {
3489
- await requireFullSetup(api.logger);
3490
- // v1 taxonomy: route explicit remembers through the same canonical
3491
- // store path that auto-extraction uses (`storeExtractedFacts`). This
3492
- // emits a Memory Taxonomy v1 JSON blob, generates entity trapdoors,
3493
- // and runs through the Phase 2 contradiction-resolution pipeline.
3494
- //
3495
- // Accept legacy v0 tokens on input and coerce to v1 via
3496
- // `normalizeToV1Type` so agents that still emit the pre-v3
3497
- // taxonomy keep working.
3498
- const rawType = typeof params.type === 'string' ? params.type.toLowerCase() : 'claim';
3499
- const memoryType = isValidMemoryType(rawType)
3500
- ? rawType
3501
- : normalizeToV1Type(rawType);
3502
- // Source defaults to 'user' for explicit remembers (the user is
3503
- // the author by definition). Ignored if the caller passes an
3504
- // invalid value.
3505
- const rawSource = typeof params.source === 'string' ? params.source.toLowerCase() : 'user';
3506
- const memorySource = VALID_MEMORY_SOURCES.includes(rawSource)
3507
- ? rawSource
3508
- : 'user';
3509
- const rawScope = typeof params.scope === 'string' ? params.scope.toLowerCase() : 'unspecified';
3510
- const memoryScope = VALID_MEMORY_SCOPES.includes(rawScope)
3511
- ? rawScope
3512
- : 'unspecified';
3513
- const reasoning = typeof params.reasoning === 'string' && params.reasoning.length > 0
3514
- ? params.reasoning.slice(0, 256)
3515
- : undefined;
3516
- // Explicit remember defaults to importance 8 (above auto-extraction's
3517
- // typical 6-7), so store-time dedup's shouldSupersede prefers the
3518
- // explicit call when it collides with an auto-extracted claim.
3519
- const importance = Math.max(1, Math.min(10, params.importance ?? 8));
3520
- const validatedEntities = Array.isArray(params.entities)
3521
- ? params.entities
3522
- .map((e) => parseEntity(e))
3523
- .filter((e) => e !== null)
3524
- : [];
3525
- const fact = {
3526
- text: params.text.slice(0, 512),
3527
- type: memoryType,
3528
- source: memorySource,
3529
- scope: memoryScope,
3530
- reasoning,
3531
- importance,
3532
- action: 'ADD',
3533
- confidence: 1.0, // user explicitly asked to remember — highest confidence
3534
- };
3535
- if (validatedEntities.length > 0)
3536
- fact.entities = validatedEntities;
3537
- const stored = await storeExtractedFacts([fact], api.logger, 'explicit');
3538
- api.logger.info(`totalreclaw_remember: routed to storeExtractedFacts (stored=${stored}, entities=${validatedEntities.length})`);
3539
- if (stored === 0) {
3540
- // Dedup or supersession consumed the write. Treat as success from
3541
- // the user's perspective — the memory's content is already in the
3542
- // vault (possibly under a different ID).
3543
- return {
3544
- content: [
3545
- {
3546
- type: 'text',
3547
- text: 'Memory noted (matched existing content in vault).',
3548
- },
3549
- ],
3550
- };
3551
- }
3552
- return {
3553
- content: [{ type: 'text', text: 'Memory encrypted and stored.' }],
3554
- };
3555
- }
3556
- catch (err) {
3557
- const message = err instanceof Error ? err.message : String(err);
3558
- api.logger.error(`totalreclaw_remember failed: ${message}`);
3559
- return {
3560
- content: [{ type: 'text', text: `Failed to store memory: ${humanizeError(message)}` }],
3561
- };
3562
- }
3563
- },
3564
- }, { name: 'totalreclaw_remember' });
3565
- // ---------------------------------------------------------------
3566
- // Tool: totalreclaw_recall
3872
+ // Hook: before_tool_call (memory-tool gate)
3567
3873
  // ---------------------------------------------------------------
3568
- api.registerTool({
3569
- name: 'totalreclaw_recall',
3570
- label: 'Recall',
3571
- description: 'Search the encrypted memory vault. Returns the most relevant memories matching the query.',
3572
- parameters: {
3573
- type: 'object',
3574
- properties: {
3575
- query: {
3576
- type: 'string',
3577
- description: 'Search query text',
3578
- },
3579
- k: {
3580
- type: 'number',
3581
- minimum: 1,
3582
- maximum: 20,
3583
- description: 'Number of results to return (default: 8)',
3584
- },
3585
- },
3586
- required: ['query'],
3587
- additionalProperties: false,
3588
- },
3589
- async execute(_toolCallId, params) {
3590
- try {
3591
- await requireFullSetup(api.logger);
3592
- const k = Math.min(params.k ?? 8, 20);
3593
- // 1. Generate word trapdoors (blind indices for the query).
3594
- const wordTrapdoors = generateBlindIndices(params.query);
3595
- // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
3596
- let queryEmbedding = null;
3597
- let lshTrapdoors = [];
3598
- try {
3599
- queryEmbedding = await generateEmbedding(params.query, { isQuery: true });
3600
- const hasher = getLSHHasher(api.logger);
3601
- if (hasher && queryEmbedding) {
3602
- lshTrapdoors = hasher.hash(queryEmbedding);
3603
- }
3604
- }
3605
- catch (err) {
3606
- const msg = err instanceof Error ? err.message : String(err);
3607
- api.logger.warn(`Recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
3608
- }
3609
- // 3. Merge word trapdoors + LSH trapdoors.
3610
- const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
3611
- if (allTrapdoors.length === 0) {
3612
- return {
3613
- content: [{ type: 'text', text: 'No searchable terms in query.' }],
3614
- details: { count: 0, memories: [] },
3615
- };
3616
- }
3617
- // 4. Request more candidates than needed so we can re-rank client-side.
3618
- // 5. Decrypt candidates (text + embeddings) and build reranker input.
3619
- const rerankerCandidates = [];
3620
- const metaMap = new Map();
3621
- if (isSubgraphMode()) {
3622
- // --- Subgraph search path ---
3623
- const factCount = await getSubgraphFactCount(subgraphOwner || userId, authKeyHex);
3624
- const pool = computeCandidatePool(factCount);
3625
- let subgraphResults = await searchSubgraph(subgraphOwner || userId, allTrapdoors, pool, authKeyHex);
3626
- // Always run broadened search and merge — ensures vocabulary mismatches
3627
- // (e.g., "preferences" vs "prefer") don't cause recall failures.
3628
- // The reranker handles scoring; extra cost is ~1 GraphQL query per recall.
3629
- try {
3630
- const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId, pool, authKeyHex);
3631
- // Merge broadened results with existing (deduplicate by ID)
3632
- const existingIds = new Set(subgraphResults.map(r => r.id));
3633
- for (const br of broadenedResults) {
3634
- if (!existingIds.has(br.id)) {
3635
- subgraphResults.push(br);
3636
- }
3637
- }
3638
- }
3639
- catch { /* best-effort */ }
3640
- for (const result of subgraphResults) {
3641
- try {
3642
- const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
3643
- if (isDigestBlob(docJson))
3644
- continue;
3645
- const doc = readClaimFromBlob(docJson);
3646
- let decryptedEmbedding;
3647
- if (result.encryptedEmbedding) {
3648
- try {
3649
- decryptedEmbedding = JSON.parse(decryptFromHex(result.encryptedEmbedding, encryptionKey));
3650
- }
3651
- catch {
3652
- // Embedding decryption failed -- proceed without it.
3653
- }
3654
- }
3655
- if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
3656
- try {
3657
- decryptedEmbedding = await generateEmbedding(doc.text);
3658
- }
3659
- catch {
3660
- decryptedEmbedding = undefined;
3661
- }
3662
- }
3663
- rerankerCandidates.push({
3664
- id: result.id,
3665
- text: doc.text,
3666
- embedding: decryptedEmbedding,
3667
- importance: doc.importance / 10,
3668
- createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
3669
- // Retrieval v2 Tier 1: surface v1 source so applySourceWeights
3670
- // can multiply the final RRF score by the source weight.
3671
- source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
3672
- });
3673
- metaMap.set(result.id, {
3674
- metadata: doc.metadata ?? {},
3675
- timestamp: Date.now(),
3676
- category: doc.category,
3677
- });
3678
- }
3679
- catch {
3680
- // Skip candidates we cannot decrypt.
3681
- }
3682
- }
3683
- // Update hot cache with top results for instant auto-recall.
3684
- try {
3685
- if (!pluginHotCache && encryptionKey) {
3686
- const config = getSubgraphConfig();
3687
- pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
3688
- pluginHotCache.load();
3689
- }
3690
- if (pluginHotCache) {
3691
- const hotFacts = rerankerCandidates.map((c) => {
3692
- const meta = metaMap.get(c.id);
3693
- const importance = meta?.metadata.importance
3694
- ? Math.round(meta.metadata.importance * 10)
3695
- : 5;
3696
- return { id: c.id, text: c.text, importance };
3697
- });
3698
- pluginHotCache.setHotFacts(hotFacts);
3699
- pluginHotCache.setFactCount(rerankerCandidates.length);
3700
- pluginHotCache.flush();
3701
- }
3702
- }
3703
- catch {
3704
- // Hot cache update is best-effort -- don't fail the recall.
3705
- }
3706
- }
3707
- else {
3708
- // --- Server search path (existing behavior) ---
3709
- const factCount = await getFactCount(api.logger);
3710
- const pool = computeCandidatePool(factCount);
3711
- const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
3712
- for (const candidate of candidates) {
3713
- try {
3714
- const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
3715
- if (isDigestBlob(docJson))
3716
- continue;
3717
- const doc = readClaimFromBlob(docJson);
3718
- let decryptedEmbedding;
3719
- if (candidate.encrypted_embedding) {
3720
- try {
3721
- decryptedEmbedding = JSON.parse(decryptFromHex(candidate.encrypted_embedding, encryptionKey));
3722
- }
3723
- catch {
3724
- // Embedding decryption failed -- proceed without it.
3725
- }
3726
- }
3727
- if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
3728
- try {
3729
- decryptedEmbedding = await generateEmbedding(doc.text);
3730
- }
3731
- catch {
3732
- decryptedEmbedding = undefined;
3733
- }
3734
- }
3735
- rerankerCandidates.push({
3736
- id: candidate.fact_id,
3737
- text: doc.text,
3738
- embedding: decryptedEmbedding,
3739
- importance: doc.importance / 10,
3740
- createdAt: typeof candidate.timestamp === 'number'
3741
- ? candidate.timestamp / 1000
3742
- : new Date(candidate.timestamp).getTime() / 1000,
3743
- source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
3744
- });
3745
- metaMap.set(candidate.fact_id, {
3746
- metadata: doc.metadata ?? {},
3747
- timestamp: candidate.timestamp,
3748
- category: doc.category,
3749
- });
3750
- }
3751
- catch {
3752
- // Skip candidates we cannot decrypt (e.g. corrupted data).
3753
- }
3754
- }
3755
- }
3756
- // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
3757
- const queryIntent = detectQueryIntent(params.query);
3758
- const reranked = rerank(params.query, queryEmbedding ?? [], rerankerCandidates, k, INTENT_WEIGHTS[queryIntent],
3759
- /* applySourceWeights (Retrieval v2 Tier 1) */ true);
3760
- if (reranked.length === 0) {
3761
- return {
3762
- content: [{ type: 'text', text: 'No memories found matching your query.' }],
3763
- details: { count: 0, memories: [] },
3764
- };
3765
- }
3766
- // 6b. Relevance gate removed in rc.22 -- core's intent-weighted
3767
- // RRF + Tier 1 source weighting handles short queries via the
3768
- // BM25 component, making the rc.18 cosine + lexical-override
3769
- // band-aid (issue #116) redundant.
3770
- // 7. Format results.
3771
- const lines = reranked.map((m, i) => {
3772
- const meta = metaMap.get(m.id);
3773
- const imp = meta?.metadata.importance
3774
- ? ` (importance: ${Math.round(meta.metadata.importance * 10)}/10)`
3775
- : '';
3776
- const age = meta ? relativeTime(meta.timestamp) : '';
3777
- const typeTag = meta?.category ? `[${meta.category}] ` : '';
3778
- return `${i + 1}. ${typeTag}${m.text}${imp} -- ${age} [ID: ${m.id}]`;
3779
- });
3780
- const formatted = lines.join('\n');
3781
- return {
3782
- content: [{ type: 'text', text: formatted }],
3783
- details: {
3784
- count: reranked.length,
3785
- memories: reranked.map((m) => ({
3786
- factId: m.id,
3787
- text: m.text,
3788
- })),
3789
- },
3790
- };
3791
- }
3792
- catch (err) {
3793
- const message = err instanceof Error ? err.message : String(err);
3794
- api.logger.error(`totalreclaw_recall failed: ${message}`);
3795
- return {
3796
- content: [{ type: 'text', text: `Failed to search memories: ${humanizeError(message)}` }],
3797
- };
3798
- }
3799
- },
3800
- }, { name: 'totalreclaw_recall' });
3801
- // ---------------------------------------------------------------
3802
- // Tool: totalreclaw_forget
3803
- // ---------------------------------------------------------------
3804
- api.registerTool({
3805
- name: 'totalreclaw_forget',
3806
- label: 'Forget',
3807
- description: 'Delete a specific memory. Use when the user asks to forget, delete, or remove ' +
3808
- 'something specific (e.g. "forget that I live in Porto", "delete the memory about my old job"). ' +
3809
- 'Writes an on-chain tombstone — the delete is permanent and propagates across all devices. ' +
3810
- 'If the user names the memory in natural language instead of an ID, FIRST call ' +
3811
- '`totalreclaw_recall` with their phrase as the query, then pass the top result\'s `id` as ' +
3812
- '`factId`. Non-reversible.',
3813
- parameters: {
3814
- type: 'object',
3815
- properties: {
3816
- factId: {
3817
- type: 'string',
3818
- description: 'The UUID of the memory to delete. Get this from a prior `totalreclaw_recall` result — ' +
3819
- 'the `memories[i].id` field. Never invent a factId; if you don\'t have one, call recall first.',
3820
- },
3821
- },
3822
- required: ['factId'],
3823
- additionalProperties: false,
3824
- },
3825
- async execute(_toolCallId, params) {
3826
- try {
3827
- await requireFullSetup(api.logger);
3828
- // Validate factId shape BEFORE any on-chain work. Prevents
3829
- // silent no-op when the LLM fabricates a non-UUID factId —
3830
- // the classic failure mode from 3.3.1-rc.1 QA where the
3831
- // agent replied "Done" without calling the tool at all, OR
3832
- // called the tool with a plain natural-language string.
3833
- const factId = typeof params.factId === 'string' ? params.factId.trim() : '';
3834
- if (!factId) {
3835
- return {
3836
- content: [{
3837
- type: 'text',
3838
- text: 'Cannot forget without a memory ID. Call `totalreclaw_recall` first with ' +
3839
- 'the user\'s phrasing as the query — the top result\'s `id` field is the ' +
3840
- 'factId to pass here.',
3841
- }],
3842
- details: { deleted: false, error: 'missing-fact-id' },
3843
- };
3844
- }
3845
- // UUID-v4-ish shape check (loose — accepts any hex-dashed id).
3846
- // Prevents cases like `factId: "that I live in Porto"` from
3847
- // reaching the UserOp path and silently failing on-chain.
3848
- const looksLikeFactId = /^[0-9a-f-]{8,}$/i.test(factId);
3849
- if (!looksLikeFactId) {
3850
- api.logger.warn(`totalreclaw_forget: rejected likely-invalid factId "${factId.slice(0, 40)}" ` +
3851
- `— expected a UUID from a prior recall result, not natural language.`);
3852
- return {
3853
- content: [{
3854
- type: 'text',
3855
- text: `"${factId.slice(0, 60)}" doesn\'t look like a memory ID. Call ` +
3856
- '`totalreclaw_recall` first with the user\'s phrasing as the query, then ' +
3857
- 'pass the top result\'s `id` field (a hex UUID) as `factId`.',
3858
- }],
3859
- details: { deleted: false, error: 'invalid-fact-id' },
3860
- };
3861
- }
3862
- if (isSubgraphMode()) {
3863
- // On-chain tombstone: write a minimal protobuf with decayScore=0
3864
- // The subgraph picks this up and sets isActive=false.
3865
- //
3866
- // 3.3.1-rc.2 fix: route through submitFactBatchOnChain with a
3867
- // single-payload batch so we share the tombstone codepath the
3868
- // pin/unpin flow uses (that flow is known-good and the QA
3869
- // confirms pin works). Also write at legacy v3 (NOT v4) so the
3870
- // subgraph handler matches the source="tombstone" + version=3
3871
- // shape the contradiction/pin tombstones use.
3872
- const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex, walletAddress: subgraphOwner ?? undefined };
3873
- const tombstone = {
3874
- id: factId,
3875
- timestamp: new Date().toISOString(),
3876
- owner: subgraphOwner || userId,
3877
- encryptedBlob: '00', // minimal 1-byte placeholder
3878
- blindIndices: [],
3879
- decayScore: 0,
3880
- source: 'tombstone',
3881
- contentFp: '',
3882
- agentId: 'openclaw-plugin-forget',
3883
- // Deliberately NO version: field → uses the default (legacy v3).
3884
- // The pin/unpin tombstones use v3 (see pin.ts:611-621) — we
3885
- // MUST match that shape or the subgraph may not flip isActive.
3886
- };
3887
- const protobuf = encodeFactProtobuf(tombstone);
3888
- const result = await submitFactBatchOnChain([protobuf], config);
3889
- if (!result.success) {
3890
- throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
3891
- }
3892
- api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
3893
- // Read-after-write: poll the subgraph until the original fact id
3894
- // is no longer active (forget flips isActive=false). On timeout
3895
- // surface `partial: true` so the agent can explain the chain
3896
- // write succeeded but the subgraph is still propagating.
3897
- const confirm = await confirmIndexed(factId, {
3898
- expect: 'inactive',
3899
- authKeyHex: authKeyHex,
3900
- });
3901
- return {
3902
- content: [{
3903
- type: 'text',
3904
- text: confirm.indexed
3905
- ? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
3906
- : `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
3907
- 'The subgraph indexer is still propagating the change — ' +
3908
- 'recall/export may briefly show the memory as still active.',
3909
- }],
3910
- details: {
3911
- deleted: true,
3912
- txHash: result.txHash,
3913
- factId,
3914
- ...(confirm.indexed ? {} : { partial: true }),
3915
- },
3916
- };
3917
- }
3918
- else {
3919
- await apiClient.deleteFact(factId, authKeyHex);
3920
- return {
3921
- content: [{ type: 'text', text: `Memory ${factId} deleted` }],
3922
- details: { deleted: true, factId },
3923
- };
3924
- }
3925
- }
3926
- catch (err) {
3927
- const message = err instanceof Error ? err.message : String(err);
3928
- api.logger.error(`totalreclaw_forget failed: ${message}`);
3929
- return {
3930
- content: [{ type: 'text', text: `Failed to delete memory: ${humanizeError(message)}` }],
3931
- };
3932
- }
3933
- },
3934
- }, { name: 'totalreclaw_forget' });
3935
- // ---------------------------------------------------------------
3936
- // Tool: totalreclaw_export
3937
- // ---------------------------------------------------------------
3938
- api.registerTool({
3939
- name: 'totalreclaw_export',
3940
- label: 'Export',
3941
- description: 'Export all stored memories. Decrypts every memory and returns them as JSON or Markdown.',
3942
- parameters: {
3943
- type: 'object',
3944
- properties: {
3945
- format: {
3946
- type: 'string',
3947
- enum: ['json', 'markdown'],
3948
- description: 'Output format (default: json)',
3949
- },
3950
- },
3951
- additionalProperties: false,
3952
- },
3953
- async execute(_toolCallId, params) {
3954
- try {
3955
- await requireFullSetup(api.logger);
3956
- const format = params.format ?? 'json';
3957
- // Paginate through all facts.
3958
- const allFacts = [];
3959
- if (isSubgraphMode()) {
3960
- // Query subgraph for all active facts (cursor-based pagination via id_gt)
3961
- const config = getSubgraphConfig();
3962
- const relayUrl = config.relayUrl;
3963
- const PAGE_SIZE = 1000;
3964
- let lastId = '';
3965
- const owner = subgraphOwner || userId || '';
3966
- console.error(`[TotalReclaw Export] owner=${owner} subgraphOwner=${subgraphOwner} userId=${userId} relayUrl=${relayUrl} authKey=${authKeyHex ? authKeyHex.slice(0, 8) + '...' : 'MISSING'} isSubgraph=${isSubgraphMode()}`);
3967
- while (true) {
3968
- const hasLastId = lastId !== '';
3969
- const query = hasLastId
3970
- ? `query($owner:Bytes!,$first:Int!,$lastId:String!){facts(where:{owner:$owner,isActive:true,id_gt:$lastId},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`
3971
- : `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`;
3972
- const variables = hasLastId
3973
- ? { owner, first: PAGE_SIZE, lastId }
3974
- : { owner, first: PAGE_SIZE };
3975
- const res = await fetch(`${relayUrl}/v1/subgraph`, {
3976
- method: 'POST',
3977
- headers: buildRelayHeaders({
3978
- 'Content-Type': 'application/json',
3979
- ...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
3980
- }),
3981
- body: JSON.stringify({ query, variables }),
3982
- });
3983
- const json = (await res.json());
3984
- // Surface relay/subgraph errors instead of silently returning empty
3985
- if (json.error || json.errors) {
3986
- const errMsg = json.error || json.errors?.map(e => e.message).join('; ') || 'Unknown error';
3987
- api.logger.error(`Export subgraph query failed: ${errMsg} (owner=${owner}, status=${res.status})`);
3988
- return {
3989
- content: [{ type: 'text', text: `Export failed: ${errMsg}` }],
3990
- };
3991
- }
3992
- const facts = json?.data?.facts || [];
3993
- if (facts.length === 0)
3994
- break;
3995
- for (const fact of facts) {
3996
- try {
3997
- let hexBlob = fact.encryptedBlob;
3998
- if (hexBlob.startsWith('0x'))
3999
- hexBlob = hexBlob.slice(2);
4000
- const docJson = decryptFromHex(hexBlob, encryptionKey);
4001
- if (isDigestBlob(docJson))
4002
- continue;
4003
- const doc = readClaimFromBlob(docJson);
4004
- allFacts.push({
4005
- id: fact.id,
4006
- text: doc.text,
4007
- metadata: doc.metadata,
4008
- created_at: new Date(parseInt(fact.timestamp) * 1000).toISOString(),
4009
- });
4010
- }
4011
- catch {
4012
- // Skip facts we cannot decrypt
4013
- }
4014
- }
4015
- if (facts.length < PAGE_SIZE)
4016
- break;
4017
- lastId = facts[facts.length - 1].id;
4018
- }
4019
- }
4020
- else {
4021
- // HTTP server mode — paginate through PostgreSQL facts
4022
- let cursor;
4023
- let hasMore = true;
4024
- while (hasMore) {
4025
- const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
4026
- for (const fact of page.facts) {
4027
- try {
4028
- const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
4029
- if (isDigestBlob(docJson))
4030
- continue;
4031
- const doc = readClaimFromBlob(docJson);
4032
- allFacts.push({
4033
- id: fact.id,
4034
- text: doc.text,
4035
- metadata: doc.metadata,
4036
- created_at: fact.created_at,
4037
- });
4038
- }
4039
- catch {
4040
- // Skip facts we cannot decrypt.
4041
- }
4042
- }
4043
- cursor = page.cursor ?? undefined;
4044
- hasMore = page.has_more;
4045
- }
4046
- }
4047
- // Format output.
4048
- let formatted;
4049
- if (format === 'markdown') {
4050
- if (allFacts.length === 0) {
4051
- formatted = '*No memories stored.*';
4052
- }
4053
- else {
4054
- const lines = allFacts.map((f, i) => {
4055
- const meta = f.metadata;
4056
- const type = meta.type ?? 'fact';
4057
- const imp = meta.importance
4058
- ? ` (importance: ${Math.round(meta.importance * 10)}/10)`
4059
- : '';
4060
- return `${i + 1}. **[${type}]** ${f.text}${imp} \n _ID: ${f.id} | Created: ${f.created_at}_`;
4061
- });
4062
- formatted = `# Exported Memories (${allFacts.length})\n\n${lines.join('\n')}`;
4063
- }
4064
- }
4065
- else {
4066
- formatted = JSON.stringify(allFacts, null, 2);
4067
- }
4068
- return {
4069
- content: [{ type: 'text', text: formatted }],
4070
- details: { count: allFacts.length },
4071
- };
4072
- }
4073
- catch (err) {
4074
- const message = err instanceof Error ? err.message : String(err);
4075
- api.logger.error(`totalreclaw_export failed: ${message}`);
4076
- return {
4077
- content: [{ type: 'text', text: `Failed to export memories: ${humanizeError(message)}` }],
4078
- };
4079
- }
4080
- },
4081
- }, { name: 'totalreclaw_export' });
4082
- // ---------------------------------------------------------------
4083
- // Tool: totalreclaw_status
4084
- // ---------------------------------------------------------------
4085
- api.registerTool({
4086
- name: 'totalreclaw_status',
4087
- label: 'Status',
4088
- description: 'Check TotalReclaw billing and subscription status — tier, writes used, reset date.',
4089
- parameters: {
4090
- type: 'object',
4091
- properties: {},
4092
- additionalProperties: false,
4093
- },
4094
- async execute() {
4095
- try {
4096
- await requireFullSetup(api.logger);
4097
- if (!authKeyHex) {
4098
- return {
4099
- content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
4100
- };
4101
- }
4102
- const serverUrl = CONFIG.serverUrl;
4103
- const walletAddr = subgraphOwner || userId || '';
4104
- const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
4105
- method: 'GET',
4106
- headers: buildRelayHeaders({
4107
- 'Authorization': `Bearer ${authKeyHex}`,
4108
- 'Accept': 'application/json',
4109
- }),
4110
- });
4111
- if (!response.ok) {
4112
- const body = await response.text().catch(() => '');
4113
- return {
4114
- content: [{ type: 'text', text: `Failed to fetch billing status (HTTP ${response.status}): ${body || response.statusText}` }],
4115
- };
4116
- }
4117
- const data = await response.json();
4118
- const tier = data.tier || 'free';
4119
- const freeWritesUsed = data.free_writes_used ?? 0;
4120
- const freeWritesLimit = data.free_writes_limit ?? 0;
4121
- const freeWritesResetAt = data.free_writes_reset_at;
4122
- // Update billing cache on success.
4123
- writeBillingCache({
4124
- tier,
4125
- free_writes_used: freeWritesUsed,
4126
- free_writes_limit: freeWritesLimit,
4127
- features: data.features,
4128
- checked_at: Date.now(),
4129
- });
4130
- // 3.3.1 (internal#130) — surface the Smart Account / scope
4131
- // address so the user can do subgraph queries, BaseScan
4132
- // lookups, and cross-client portability checks BEFORE any
4133
- // chain write completes. Resolution priority:
4134
- // 1. In-memory `subgraphOwner` (already derived earlier).
4135
- // 2. credentials.json `scope_address` (persisted at pair).
4136
- // 3. Lazy derive now from the loaded mnemonic + cache it
4137
- // back to credentials.json so the next call is free.
4138
- let scopeAddress = subgraphOwner ?? undefined;
4139
- if (!scopeAddress) {
4140
- try {
4141
- const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
4142
- if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
4143
- scopeAddress = credsCache.scope_address;
4144
- }
4145
- else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
4146
- scopeAddress = await deriveSmartAccountAddress(credsCache.mnemonic, CONFIG.chainId);
4147
- if (scopeAddress) {
4148
- writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
4149
- }
4150
- }
4151
- }
4152
- catch (deriveErr) {
4153
- api.logger.warn(`totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
4154
- }
4155
- }
4156
- const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
4157
- const lines = [
4158
- `Tier: ${tierLabel}`,
4159
- `Writes: ${freeWritesUsed}/${freeWritesLimit} used this month`,
4160
- ];
4161
- if (freeWritesResetAt) {
4162
- lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
4163
- }
4164
- if (scopeAddress) {
4165
- lines.push(`Smart Account: ${scopeAddress}`);
4166
- }
4167
- if (tier !== 'pro') {
4168
- lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
4169
- }
4170
- return {
4171
- content: [{ type: 'text', text: lines.join('\n') }],
4172
- details: {
4173
- tier,
4174
- free_writes_used: freeWritesUsed,
4175
- free_writes_limit: freeWritesLimit,
4176
- scope_address: scopeAddress,
4177
- },
4178
- };
4179
- }
4180
- catch (err) {
4181
- const message = err instanceof Error ? err.message : String(err);
4182
- api.logger.error(`totalreclaw_status failed: ${message}`);
4183
- return {
4184
- content: [{ type: 'text', text: `Failed to check status: ${humanizeError(message)}` }],
4185
- };
4186
- }
4187
- },
4188
- }, { name: 'totalreclaw_status' });
4189
- // ---------------------------------------------------------------
4190
- // Tool: totalreclaw_preload_embedder (3.3.3-rc.1 — issue #187)
4191
- // ---------------------------------------------------------------
4192
- //
4193
- // Decouples the ~700 MB embedder bundle download from the pair-
4194
- // completion gate. The agent can call this BEFORE
4195
- // `totalreclaw_pair` to pre-flight disk space, kick off the
4196
- // download, and surface the completion state. The non-blocking
4197
- // prefetch in `register()` already starts the download
4198
- // unconditionally; this tool is an explicit on-demand hook for
4199
- // agents that want to confirm completion (or trigger a retry on
4200
- // network failure) without first completing pair.
4201
- //
4202
- // Behavior:
4203
- // - Disk-space pre-flight: refuses if free disk < 500 MB on the
4204
- // embedder cache mount. Surfaces the path + free bytes so the
4205
- // user can clear space.
4206
- // - Triggers `prefetchEmbedderBundle()` (idempotent — cache hit
4207
- // returns immediately).
4208
- // - Returns a structured success message with status:
4209
- // `cache_hit | fetched | failed`.
4210
- //
4211
- // Phrase-safety: this tool does NOT touch credentials.json,
4212
- // mnemonics, or keys. It only touches `~/.totalreclaw/embedder/`
4213
- // and the GitHub Releases CDN.
4214
- api.registerTool({
4215
- name: 'totalreclaw_preload_embedder',
4216
- label: 'Preload Embedder',
4217
- description: 'Download the local-embedding model bundle ahead of pair completion. Use this when the user wants to set up TotalReclaw on a slow connection or run an offline-after-setup workflow. Returns success once the ~700 MB bundle is cached at ~/.totalreclaw/embedder/.',
4218
- parameters: {
4219
- type: 'object',
4220
- properties: {},
4221
- additionalProperties: false,
4222
- },
4223
- async execute() {
4224
- try {
4225
- // Disk-space pre-flight: refuse if < 500 MB free on the
4226
- // embedder cache mount. Best-effort — if statfs fails, we
4227
- // proceed without the pre-flight rather than blocking.
4228
- const fsModule = await import('node:fs');
4229
- const cacheRoot = CONFIG.embedderCachePath;
4230
- const REQUIRED_BYTES = 500 * 1024 * 1024;
4231
- try {
4232
- // Find the deepest existing parent so statfs has a real
4233
- // mount to measure (loadEmbedder will mkdir under it
4234
- // anyway).
4235
- let probeDir = cacheRoot;
4236
- while (true) {
4237
- try {
4238
- fsModule.statSync(probeDir);
4239
- break;
4240
- }
4241
- catch {
4242
- const parent = nodePath.dirname(probeDir);
4243
- if (parent === probeDir)
4244
- break;
4245
- probeDir = parent;
4246
- }
4247
- }
4248
- const stats = fsModule.statfsSync?.(probeDir);
4249
- if (stats) {
4250
- const bavail = typeof stats.bavail === 'bigint' ? Number(stats.bavail) : stats.bavail;
4251
- const bsize = typeof stats.bsize === 'bigint' ? Number(stats.bsize) : stats.bsize;
4252
- const freeBytes = bavail * bsize;
4253
- if (freeBytes > 0 && freeBytes < REQUIRED_BYTES) {
4254
- const freeMb = Math.round(freeBytes / (1024 * 1024));
4255
- return {
4256
- content: [{
4257
- type: 'text',
4258
- text: `Insufficient free disk space for embedder bundle. Required: 500 MB. Available at ${probeDir}: ${freeMb} MB. Free up space and retry.`,
4259
- }],
4260
- details: { status: 'failed', reason: 'disk_space', free_mb: freeMb, required_mb: 500, cache_root: cacheRoot },
4261
- };
4262
- }
4263
- }
4264
- }
4265
- catch {
4266
- // statfs probe failed — surface a soft warning in logs
4267
- // but proceed with the download anyway.
4268
- api.logger.info('totalreclaw_preload_embedder: disk-space probe unavailable, proceeding without pre-flight');
4269
- }
4270
- // Trigger the prefetch. This is idempotent (cache hit returns
4271
- // immediately) so it's safe to invoke even when the
4272
- // background prefetch from register() already completed.
4273
- const result = await prefetchEmbedderBundle({
4274
- log: (msg) => api.logger.info(msg),
4275
- });
4276
- const human = result === 'fetched'
4277
- ? `Embedder bundle downloaded and cached at ${cacheRoot}. Subsequent embedding calls run in-memory.`
4278
- : `Embedder bundle already cached at ${cacheRoot} — no download needed.`;
4279
- return {
4280
- content: [{ type: 'text', text: human }],
4281
- details: { status: result, cache_root: cacheRoot },
4282
- };
4283
- }
4284
- catch (err) {
4285
- const message = err instanceof Error ? err.message : String(err);
4286
- api.logger.error(`totalreclaw_preload_embedder failed: ${message}`);
4287
- return {
4288
- content: [{
4289
- type: 'text',
4290
- text: `Embedder bundle preload failed: ${humanizeError(message)}. The plugin will retry on first embedding call.`,
4291
- }],
4292
- details: { status: 'failed', reason: 'fetch_error', message },
4293
- };
4294
- }
4295
- },
4296
- }, { name: 'totalreclaw_preload_embedder' });
4297
- // ---------------------------------------------------------------
4298
- // Tool: totalreclaw_consolidate
4299
- // ---------------------------------------------------------------
4300
- api.registerTool({
4301
- name: 'totalreclaw_consolidate',
4302
- label: 'Consolidate',
4303
- description: 'Deduplicate and merge related memories. Self-hosted mode only.',
4304
- parameters: {
4305
- type: 'object',
4306
- properties: {
4307
- dry_run: {
4308
- type: 'boolean',
4309
- description: 'Preview only (default: false)',
4310
- },
4311
- },
4312
- additionalProperties: false,
4313
- },
4314
- async execute(_toolCallId, params) {
4315
- try {
4316
- await requireFullSetup(api.logger);
4317
- const dryRun = params.dry_run ?? false;
4318
- // Consolidation is only available in centralized (HTTP server) mode.
4319
- if (isSubgraphMode()) {
4320
- return {
4321
- content: [{ type: 'text', text: 'Consolidation is currently only available in centralized mode.' }],
4322
- };
4323
- }
4324
- if (!apiClient || !authKeyHex || !encryptionKey) {
4325
- return {
4326
- content: [{ type: 'text', text: 'Plugin not fully initialized. Cannot consolidate.' }],
4327
- };
4328
- }
4329
- // 1. Export all facts (paginated, max 10 pages of 1000).
4330
- const allDecrypted = [];
4331
- let cursor;
4332
- let hasMore = true;
4333
- let pageCount = 0;
4334
- const MAX_PAGES = 10;
4335
- while (hasMore && pageCount < MAX_PAGES) {
4336
- const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
4337
- for (const fact of page.facts) {
4338
- try {
4339
- const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
4340
- if (isDigestBlob(docJson))
4341
- continue;
4342
- const doc = readClaimFromBlob(docJson);
4343
- let embedding = null;
4344
- try {
4345
- embedding = await generateEmbedding(doc.text);
4346
- }
4347
- catch { /* skip — fact will not be clustered */ }
4348
- allDecrypted.push({
4349
- id: fact.id,
4350
- text: doc.text,
4351
- embedding,
4352
- importance: doc.importance,
4353
- decayScore: fact.decay_score,
4354
- createdAt: new Date(fact.created_at).getTime(),
4355
- version: fact.version,
4356
- });
4357
- }
4358
- catch {
4359
- // Skip undecryptable facts.
4360
- }
4361
- }
4362
- cursor = page.cursor ?? undefined;
4363
- hasMore = page.has_more;
4364
- pageCount++;
4365
- }
4366
- if (allDecrypted.length === 0) {
4367
- return {
4368
- content: [{ type: 'text', text: 'No memories found to consolidate.' }],
4369
- };
4370
- }
4371
- // 2. Cluster by cosine similarity.
4372
- const clusters = clusterFacts(allDecrypted, getConsolidationThreshold());
4373
- if (clusters.length === 0) {
4374
- return {
4375
- content: [{ type: 'text', text: `Scanned ${allDecrypted.length} memories — no near-duplicates found.` }],
4376
- };
4377
- }
4378
- // 3. Build report.
4379
- const totalDuplicates = clusters.reduce((sum, c) => sum + c.duplicates.length, 0);
4380
- const reportLines = [
4381
- `Scanned ${allDecrypted.length} memories.`,
4382
- `Found ${clusters.length} cluster(s) with ${totalDuplicates} duplicate(s).`,
4383
- '',
4384
- ];
4385
- const displayClusters = clusters.slice(0, 10);
4386
- for (let i = 0; i < displayClusters.length; i++) {
4387
- const cluster = displayClusters[i];
4388
- reportLines.push(`Cluster ${i + 1}: KEEP "${cluster.representative.text.slice(0, 80)}…"`);
4389
- for (const dup of cluster.duplicates) {
4390
- reportLines.push(` - REMOVE "${dup.text.slice(0, 80)}…" (ID: ${dup.id})`);
4391
- }
4392
- }
4393
- if (clusters.length > 10) {
4394
- reportLines.push(`... and ${clusters.length - 10} more cluster(s).`);
4395
- }
4396
- // 4. If not dry_run, batch-delete duplicates.
4397
- if (!dryRun) {
4398
- const idsToDelete = clusters.flatMap((c) => c.duplicates.map((d) => d.id));
4399
- const BATCH_SIZE = 500;
4400
- let totalDeleted = 0;
4401
- for (let i = 0; i < idsToDelete.length; i += BATCH_SIZE) {
4402
- const batch = idsToDelete.slice(i, i + BATCH_SIZE);
4403
- const deleted = await apiClient.batchDelete(batch, authKeyHex);
4404
- totalDeleted += deleted;
4405
- }
4406
- reportLines.push('');
4407
- reportLines.push(`Deleted ${totalDeleted} duplicate memories.`);
4408
- }
4409
- else {
4410
- reportLines.push('');
4411
- reportLines.push('DRY RUN — no memories were deleted. Run without dry_run to apply.');
4412
- }
4413
- return {
4414
- content: [{ type: 'text', text: reportLines.join('\n') }],
4415
- details: {
4416
- scanned: allDecrypted.length,
4417
- clusters: clusters.length,
4418
- duplicates: totalDuplicates,
4419
- dry_run: dryRun,
4420
- },
4421
- };
4422
- }
4423
- catch (err) {
4424
- const message = err instanceof Error ? err.message : String(err);
4425
- api.logger.error(`totalreclaw_consolidate failed: ${message}`);
4426
- return {
4427
- content: [{ type: 'text', text: `Failed to consolidate memories: ${humanizeError(message)}` }],
4428
- };
4429
- }
4430
- },
4431
- }, { name: 'totalreclaw_consolidate' });
4432
- // ---------------------------------------------------------------
4433
- // Helper: build PinOpDeps bound to the live plugin state
4434
- // ---------------------------------------------------------------
4435
- // Wires the pure pin/unpin operation to the managed-service transport +
4436
- // crypto layer. Mirrors MCP's buildPinDepsFromState and Python's
4437
- // _change_claim_status argument plumbing.
4438
- const buildPinDeps = () => {
4439
- const owner = subgraphOwner || userId || '';
4440
- const config = {
4441
- ...getSubgraphConfig(),
4442
- authKeyHex: authKeyHex,
4443
- walletAddress: subgraphOwner ?? undefined,
4444
- };
4445
- return {
4446
- owner,
4447
- sourceAgent: 'openclaw-plugin',
4448
- fetchFactById: (factId) => fetchFactById(owner, factId, authKeyHex),
4449
- decryptBlob: (hex) => decryptFromHex(hex, encryptionKey),
4450
- encryptBlob: (plaintext) => encryptToHex(plaintext, encryptionKey),
4451
- submitBatch: async (payloads) => {
4452
- const result = await submitFactBatchOnChain(payloads, config);
4453
- return { txHash: result.txHash, success: result.success };
4454
- },
4455
- generateIndices: async (text, entityNames) => {
4456
- if (!text)
4457
- return { blindIndices: [] };
4458
- const wordIndices = generateBlindIndices(text);
4459
- let lshIndices = [];
4460
- let encryptedEmbedding;
4461
- try {
4462
- const embedding = await generateEmbedding(text);
4463
- const hasher = getLSHHasher(api.logger);
4464
- if (hasher)
4465
- lshIndices = hasher.hash(embedding);
4466
- encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey);
4467
- }
4468
- catch {
4469
- // Best-effort: word + entity trapdoors alone still surface the claim.
4470
- }
4471
- const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
4472
- return {
4473
- blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
4474
- encryptedEmbedding,
4475
- };
4476
- },
4477
- };
4478
- };
4479
- // ---------------------------------------------------------------
4480
- // Tool: totalreclaw_pin
4481
- // ---------------------------------------------------------------
4482
- api.registerTool({
4483
- name: 'totalreclaw_pin',
4484
- label: 'Pin',
4485
- description: 'Pin a memory so the auto-resolution engine will never override or supersede it. ' +
4486
- "Use when the user explicitly confirms a claim is still valid after you or another agent " +
4487
- "tried to retract/contradict it (e.g. 'wait, I still use Vim sometimes'). " +
4488
- 'Takes fact_id (from a prior recall result). Pinning is idempotent — pinning an already-pinned ' +
4489
- 'claim is a no-op. Cross-device: the pin propagates via the on-chain supersession chain.',
4490
- parameters: {
4491
- type: 'object',
4492
- properties: {
4493
- fact_id: {
4494
- type: 'string',
4495
- description: 'The ID of the fact to pin (from a totalreclaw_recall result).',
4496
- },
4497
- reason: {
4498
- type: 'string',
4499
- description: 'Optional human-readable reason for pinning (logged locally for tuning).',
4500
- },
4501
- },
4502
- required: ['fact_id'],
4503
- additionalProperties: false,
4504
- },
4505
- async execute(_toolCallId, params) {
4506
- try {
4507
- await requireFullSetup(api.logger);
4508
- if (!isSubgraphMode()) {
4509
- return {
4510
- content: [{
4511
- type: 'text',
4512
- text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4513
- }],
4514
- };
4515
- }
4516
- const validation = validatePinArgs(params);
4517
- if (!validation.ok) {
4518
- return { content: [{ type: 'text', text: validation.error }] };
4519
- }
4520
- const deps = buildPinDeps();
4521
- const result = await executePinOperation(validation.factId, 'pinned', deps, validation.reason);
4522
- if (result.success && result.idempotent) {
4523
- api.logger.info(`totalreclaw_pin: ${result.fact_id} already pinned (no-op)`);
4524
- return {
4525
- content: [{ type: 'text', text: `Memory ${result.fact_id} is already pinned.` }],
4526
- details: result,
4527
- };
4528
- }
4529
- if (result.success) {
4530
- api.logger.info(`totalreclaw_pin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
4531
- return {
4532
- content: [{
4533
- type: 'text',
4534
- text: `Pinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4535
- }],
4536
- details: result,
4537
- };
4538
- }
4539
- api.logger.error(`totalreclaw_pin failed: ${result.error}`);
4540
- return {
4541
- content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
4542
- details: result,
4543
- };
4544
- }
4545
- catch (err) {
4546
- const message = err instanceof Error ? err.message : String(err);
4547
- api.logger.error(`totalreclaw_pin failed: ${message}`);
4548
- return {
4549
- content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(message)}` }],
4550
- };
4551
- }
4552
- },
4553
- }, { name: 'totalreclaw_pin' });
4554
- // ---------------------------------------------------------------
4555
- // Tool: totalreclaw_unpin
4556
- // ---------------------------------------------------------------
4557
- api.registerTool({
4558
- name: 'totalreclaw_unpin',
4559
- label: 'Unpin',
4560
- description: 'Remove the pin from a previously pinned memory, returning it to active status so the ' +
4561
- 'auto-resolution engine can supersede or retract it again. Takes fact_id. Idempotent — ' +
4562
- 'unpinning a non-pinned claim is a no-op.',
4563
- parameters: {
4564
- type: 'object',
4565
- properties: {
4566
- fact_id: {
4567
- type: 'string',
4568
- description: 'The ID of the fact to unpin (from a totalreclaw_recall result).',
4569
- },
4570
- },
4571
- required: ['fact_id'],
4572
- additionalProperties: false,
4573
- },
4574
- async execute(_toolCallId, params) {
4575
- try {
4576
- await requireFullSetup(api.logger);
4577
- if (!isSubgraphMode()) {
4578
- return {
4579
- content: [{
4580
- type: 'text',
4581
- text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4582
- }],
4583
- };
4584
- }
4585
- const validation = validatePinArgs(params);
4586
- if (!validation.ok) {
4587
- return { content: [{ type: 'text', text: validation.error }] };
4588
- }
4589
- const deps = buildPinDeps();
4590
- const result = await executePinOperation(validation.factId, 'active', deps);
4591
- if (result.success && result.idempotent) {
4592
- api.logger.info(`totalreclaw_unpin: ${result.fact_id} already active (no-op)`);
4593
- return {
4594
- content: [{ type: 'text', text: `Memory ${result.fact_id} is not pinned.` }],
4595
- details: result,
4596
- };
4597
- }
4598
- if (result.success) {
4599
- api.logger.info(`totalreclaw_unpin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
4600
- return {
4601
- content: [{
4602
- type: 'text',
4603
- text: `Unpinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4604
- }],
4605
- details: result,
4606
- };
4607
- }
4608
- api.logger.error(`totalreclaw_unpin failed: ${result.error}`);
4609
- return {
4610
- content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
4611
- details: result,
4612
- };
4613
- }
4614
- catch (err) {
4615
- const message = err instanceof Error ? err.message : String(err);
4616
- api.logger.error(`totalreclaw_unpin failed: ${message}`);
4617
- return {
4618
- content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(message)}` }],
4619
- };
4620
- }
4621
- },
4622
- }, { name: 'totalreclaw_unpin' });
4623
- // ---------------------------------------------------------------
4624
- // Shared deps for retype + set_scope (same shape as pin deps).
4625
- // Built lazily so the closure captures the current encryption key /
4626
- // subgraph owner at call time rather than at register() time.
4627
- // ---------------------------------------------------------------
4628
- const buildRetypeSetScopeDeps = () => {
4629
- const owner = subgraphOwner || userId || '';
4630
- const config = {
4631
- ...getSubgraphConfig(),
4632
- authKeyHex: authKeyHex,
4633
- walletAddress: subgraphOwner ?? undefined,
4634
- };
4635
- return {
4636
- owner,
4637
- sourceAgent: 'openclaw-plugin',
4638
- fetchFactById: (factId) => fetchFactById(owner, factId, authKeyHex),
4639
- decryptBlob: (hex) => decryptFromHex(hex, encryptionKey),
4640
- encryptBlob: (plaintext) => encryptToHex(plaintext, encryptionKey),
4641
- submitBatch: async (payloads) => {
4642
- const result = await submitFactBatchOnChain(payloads, config);
4643
- return { txHash: result.txHash, success: result.success };
4644
- },
4645
- generateIndices: async (text, entityNames) => {
4646
- if (!text)
4647
- return { blindIndices: [] };
4648
- const wordIndices = generateBlindIndices(text);
4649
- let lshIndices = [];
4650
- let encryptedEmbedding;
4651
- try {
4652
- const embedding = await generateEmbedding(text);
4653
- const hasher = getLSHHasher(api.logger);
4654
- if (hasher)
4655
- lshIndices = hasher.hash(embedding);
4656
- encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey);
4657
- }
4658
- catch {
4659
- // Best-effort: word + entity trapdoors alone still surface the claim.
4660
- }
4661
- const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
4662
- return {
4663
- blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
4664
- encryptedEmbedding,
4665
- };
4666
- },
4667
- };
4668
- };
4669
- // ---------------------------------------------------------------
4670
- // Tool: totalreclaw_retype (3.3.1-rc.2 — agent-facing taxonomy edit)
4671
- // ---------------------------------------------------------------
4672
- api.registerTool({
4673
- name: 'totalreclaw_retype',
4674
- label: 'Retype',
4675
- description: 'Reclassify an existing memory from one taxonomy type to another (claim / preference / ' +
4676
- 'directive / commitment / episode / summary). Use when the user corrects a memory\'s ' +
4677
- 'category — e.g. "that\'s actually a preference, not a fact" or "file this as a ' +
4678
- 'commitment, not a claim". Writes a new v1.1 blob with the updated type and tombstones ' +
4679
- 'the old fact on-chain.\n\n' +
4680
- 'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
4681
- 'find the fact_id, then pass it here with the new type.',
4682
- parameters: {
4683
- type: 'object',
4684
- properties: {
4685
- fact_id: {
4686
- type: 'string',
4687
- description: 'The UUID of the memory to reclassify. Get this from a prior ' +
4688
- '`totalreclaw_recall` result.',
4689
- },
4690
- new_type: {
4691
- type: 'string',
4692
- enum: ['claim', 'preference', 'directive', 'commitment', 'episode', 'summary'],
4693
- description: 'The new taxonomy type. claim=factual statement, preference=opinion/like/dislike, ' +
4694
- 'directive=instruction, commitment=promise/plan, episode=event, summary=aggregate.',
4695
- },
4696
- },
4697
- required: ['fact_id', 'new_type'],
4698
- additionalProperties: false,
4699
- },
4700
- async execute(_toolCallId, params) {
4701
- try {
4702
- await requireFullSetup(api.logger);
4703
- if (!isSubgraphMode()) {
4704
- return {
4705
- content: [{
4706
- type: 'text',
4707
- text: 'Retype is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4708
- }],
4709
- };
4710
- }
4711
- const validation = validateRetypeArgs(params);
4712
- if (!validation.ok) {
4713
- return { content: [{ type: 'text', text: validation.error }] };
4714
- }
4715
- const deps = buildRetypeSetScopeDeps();
4716
- const result = await executeRetype(validation.factId, validation.newType, deps);
4717
- if (result.success) {
4718
- api.logger.info(`totalreclaw_retype: ${result.fact_id} (${result.previous_type} → ${result.new_type}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
4719
- return {
4720
- content: [{
4721
- type: 'text',
4722
- text: `Retyped memory ${result.fact_id} from ${result.previous_type} to ${result.new_type}. ` +
4723
- `New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4724
- }],
4725
- details: result,
4726
- };
4727
- }
4728
- api.logger.error(`totalreclaw_retype failed: ${result.error}`);
4729
- return {
4730
- content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(result.error ?? 'unknown error')}` }],
4731
- details: result,
4732
- };
4733
- }
4734
- catch (err) {
4735
- const message = err instanceof Error ? err.message : String(err);
4736
- api.logger.error(`totalreclaw_retype failed: ${message}`);
4737
- return {
4738
- content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(message)}` }],
4739
- };
4740
- }
4741
- },
4742
- }, { name: 'totalreclaw_retype' });
4743
- // ---------------------------------------------------------------
4744
- // Tool: totalreclaw_set_scope (3.3.1-rc.2 — agent-facing scope edit)
4745
- // ---------------------------------------------------------------
4746
- api.registerTool({
4747
- name: 'totalreclaw_set_scope',
4748
- label: 'Set Scope',
4749
- description: 'Move an existing memory to a different scope (work / personal / health / family / ' +
4750
- 'creative / finance / misc / unspecified). Use when the user re-categorizes a memory\'s ' +
4751
- 'domain — e.g. "put that under work", "this is a health thing", "move this to personal". ' +
4752
- 'Writes a new v1.1 blob with the updated scope and tombstones the old fact on-chain.\n\n' +
4753
- 'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
4754
- 'find the fact_id, then pass it here with the new scope.',
4755
- parameters: {
4756
- type: 'object',
4757
- properties: {
4758
- fact_id: {
4759
- type: 'string',
4760
- description: 'The UUID of the memory to rescope. Get this from a prior `totalreclaw_recall` result.',
4761
- },
4762
- new_scope: {
4763
- type: 'string',
4764
- enum: ['work', 'personal', 'health', 'family', 'creative', 'finance', 'misc', 'unspecified'],
4765
- description: 'The new scope. Used for filtered recall — e.g. "recall work-related memories only".',
4766
- },
4767
- },
4768
- required: ['fact_id', 'new_scope'],
4769
- additionalProperties: false,
4770
- },
4771
- async execute(_toolCallId, params) {
4772
- try {
4773
- await requireFullSetup(api.logger);
4774
- if (!isSubgraphMode()) {
4775
- return {
4776
- content: [{
4777
- type: 'text',
4778
- text: 'Set-scope is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4779
- }],
4780
- };
4781
- }
4782
- const validation = validateSetScopeArgs(params);
4783
- if (!validation.ok) {
4784
- return { content: [{ type: 'text', text: validation.error }] };
4785
- }
4786
- const deps = buildRetypeSetScopeDeps();
4787
- const result = await executeSetScope(validation.factId, validation.newScope, deps);
4788
- if (result.success) {
4789
- api.logger.info(`totalreclaw_set_scope: ${result.fact_id} (${result.previous_scope ?? 'unspecified'} → ${result.new_scope}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
4790
- return {
4791
- content: [{
4792
- type: 'text',
4793
- text: `Moved memory ${result.fact_id} from scope "${result.previous_scope ?? 'unspecified'}" to "${result.new_scope}". ` +
4794
- `New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4795
- }],
4796
- details: result,
4797
- };
4798
- }
4799
- api.logger.error(`totalreclaw_set_scope failed: ${result.error}`);
4800
- return {
4801
- content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(result.error ?? 'unknown error')}` }],
4802
- details: result,
4803
- };
4804
- }
4805
- catch (err) {
4806
- const message = err instanceof Error ? err.message : String(err);
4807
- api.logger.error(`totalreclaw_set_scope failed: ${message}`);
4808
- return {
4809
- content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(message)}` }],
4810
- };
4811
- }
4812
- },
4813
- }, { name: 'totalreclaw_set_scope' });
4814
- // ---------------------------------------------------------------
4815
- // Tool: totalreclaw_import_from
4816
- // ---------------------------------------------------------------
4817
- api.registerTool({
4818
- name: 'totalreclaw_import_from',
4819
- label: 'Import From',
4820
- description: 'Import memories from other AI memory tools (Mem0, MCP Memory Server, ChatGPT, Claude, Gemini, MemoClaw, or generic JSON/CSV). ' +
4821
- 'Provide the source name and either an API key, file content, or file path. ' +
4822
- 'Use dry_run=true to preview before importing. Idempotent — safe to run multiple times.',
4823
- parameters: {
4824
- type: 'object',
4825
- properties: {
4826
- source: {
4827
- type: 'string',
4828
- enum: ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'],
4829
- description: 'The source system to import from (gemini: Google Takeout HTML; chatgpt: conversations.json or memory text; claude: memory text)',
4830
- },
4831
- api_key: {
4832
- type: 'string',
4833
- description: 'API key for the source system (used once, never stored)',
4834
- },
4835
- source_user_id: {
4836
- type: 'string',
4837
- description: 'User or agent ID in the source system',
4838
- },
4839
- content: {
4840
- type: 'string',
4841
- description: 'File content (JSON, JSONL, or CSV)',
4842
- },
4843
- file_path: {
4844
- type: 'string',
4845
- description: 'Path to the file on disk',
4846
- },
4847
- namespace: {
4848
- type: 'string',
4849
- description: 'Target namespace (default: "imported")',
4850
- },
4851
- dry_run: {
4852
- type: 'boolean',
4853
- description: 'Preview without importing',
4854
- },
4855
- },
4856
- required: ['source'],
4857
- },
4858
- async execute(_toolCallId, params) {
4859
- try {
4860
- await requireFullSetup(api.logger);
4861
- return handlePluginImportFrom(params, api.logger);
4862
- }
4863
- catch (err) {
4864
- const message = err instanceof Error ? err.message : String(err);
4865
- return { error: message };
4866
- }
4867
- },
4868
- }, { name: 'totalreclaw_import_from' });
4869
- // ---------------------------------------------------------------
4870
- // Tool: totalreclaw_import_batch
4871
- // ---------------------------------------------------------------
4872
- api.registerTool({
4873
- name: 'totalreclaw_import_batch',
4874
- label: 'Import Batch',
4875
- description: 'Process one batch of a large import. Call repeatedly with increasing offset until is_complete=true.',
4876
- parameters: {
4877
- type: 'object',
4878
- properties: {
4879
- source: {
4880
- type: 'string',
4881
- enum: ['gemini', 'chatgpt', 'claude'],
4882
- description: 'Source format',
4883
- },
4884
- file_path: {
4885
- type: 'string',
4886
- description: 'Path to source file',
4887
- },
4888
- content: {
4889
- type: 'string',
4890
- description: 'File content (text sources)',
4891
- },
4892
- offset: {
4893
- type: 'number',
4894
- description: 'Starting chunk index (0-based)',
4895
- },
4896
- batch_size: {
4897
- type: 'number',
4898
- description: 'Chunks per call (default 25)',
4899
- },
4900
- },
4901
- required: ['source'],
4902
- },
4903
- async execute(_toolCallId, params) {
4904
- try {
4905
- await requireFullSetup(api.logger);
4906
- return handleBatchImport(params, api.logger);
4907
- }
4908
- catch (err) {
4909
- const message = err instanceof Error ? err.message : String(err);
4910
- return { error: message };
4911
- }
4912
- },
4913
- }, { name: 'totalreclaw_import_batch' });
4914
- // ---------------------------------------------------------------
4915
- // Tool: totalreclaw_import_status
4916
- // ---------------------------------------------------------------
4917
- api.registerTool({
4918
- name: 'totalreclaw_import_status',
4919
- label: 'Import Status',
4920
- description: 'Check the progress of a background import. ' +
4921
- 'If import_id is omitted, returns the most recent active import. ' +
4922
- 'Returns status (running/completed/failed/aborted), batch progress, facts stored, and ETA. ' +
4923
- 'Use this when the user asks "how\'s the import?" or "is it done yet?".',
4924
- parameters: {
4925
- type: 'object',
4926
- properties: {
4927
- import_id: {
4928
- type: 'string',
4929
- description: 'The import ID returned by totalreclaw_import_from. Omit for most recent active import.',
4930
- },
4931
- },
4932
- additionalProperties: false,
4933
- },
4934
- async execute(_toolCallId, params) {
4935
- try {
4936
- await requireFullSetup(api.logger);
4937
- return handleImportStatus(params, api.logger);
4938
- }
4939
- catch (err) {
4940
- const message = err instanceof Error ? err.message : String(err);
4941
- return { error: message };
4942
- }
4943
- },
4944
- }, { name: 'totalreclaw_import_status' });
4945
- // ---------------------------------------------------------------
4946
- // Tool: totalreclaw_import_abort
4947
- // ---------------------------------------------------------------
4948
- api.registerTool({
4949
- name: 'totalreclaw_import_abort',
4950
- label: 'Abort Import',
4951
- description: 'Cancel a running background import. Already-stored facts are kept (import is idempotent via fingerprint dedup). ' +
4952
- 'The background task will stop at the next chunk boundary after receiving the abort signal. ' +
4953
- 'Use when the user says "stop the import" or "cancel the import".',
4954
- parameters: {
4955
- type: 'object',
4956
- properties: {
4957
- import_id: {
4958
- type: 'string',
4959
- description: 'The import ID to abort (from totalreclaw_import_from or totalreclaw_import_status).',
4960
- },
4961
- },
4962
- required: ['import_id'],
4963
- additionalProperties: false,
4964
- },
4965
- async execute(_toolCallId, params) {
4966
- try {
4967
- await requireFullSetup(api.logger);
4968
- return handleImportAbort(params, api.logger);
4969
- }
4970
- catch (err) {
4971
- const message = err instanceof Error ? err.message : String(err);
4972
- return { error: message };
4973
- }
4974
- },
4975
- }, { name: 'totalreclaw_import_abort' });
4976
- // ---------------------------------------------------------------
4977
- // Tool: totalreclaw_upgrade
4978
- // ---------------------------------------------------------------
4979
- api.registerTool({
4980
- name: 'totalreclaw_upgrade',
4981
- label: 'Upgrade to Pro',
4982
- description: 'Upgrade to TotalReclaw Pro for unlimited encrypted memories. ' +
4983
- 'Returns a Stripe checkout URL for the user to complete payment via credit/debit card.',
4984
- parameters: {
4985
- type: 'object',
4986
- properties: {},
4987
- additionalProperties: false,
4988
- },
4989
- async execute() {
4990
- try {
4991
- await requireFullSetup(api.logger);
4992
- if (!authKeyHex) {
4993
- return {
4994
- content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
4995
- };
4996
- }
4997
- const serverUrl = CONFIG.serverUrl;
4998
- const walletAddr = subgraphOwner || userId || '';
4999
- if (!walletAddr) {
5000
- return {
5001
- content: [{ type: 'text', text: 'Wallet address not available. Please ensure the plugin is fully initialized.' }],
5002
- };
5003
- }
5004
- const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
5005
- method: 'POST',
5006
- headers: buildRelayHeaders({
5007
- 'Authorization': `Bearer ${authKeyHex}`,
5008
- 'Content-Type': 'application/json',
5009
- }),
5010
- body: JSON.stringify({
5011
- wallet_address: walletAddr,
5012
- tier: 'pro',
5013
- }),
5014
- });
5015
- if (!response.ok) {
5016
- const body = await response.text().catch(() => '');
5017
- return {
5018
- content: [{ type: 'text', text: `Failed to create checkout session (HTTP ${response.status}): ${body || response.statusText}` }],
5019
- };
5020
- }
5021
- const data = await response.json();
5022
- if (!data.checkout_url) {
5023
- return {
5024
- content: [{ type: 'text', text: 'Failed to create checkout session: no checkout URL returned.' }],
5025
- };
5026
- }
5027
- return {
5028
- content: [{ type: 'text', text: `Open this URL to upgrade to Pro: ${data.checkout_url}` }],
5029
- details: { checkout_url: data.checkout_url },
5030
- };
5031
- }
5032
- catch (err) {
5033
- const message = err instanceof Error ? err.message : String(err);
5034
- api.logger.error(`totalreclaw_upgrade failed: ${message}`);
5035
- return {
5036
- content: [{ type: 'text', text: `Failed to create checkout session: ${humanizeError(message)}` }],
5037
- };
5038
- }
5039
- },
5040
- }, { name: 'totalreclaw_upgrade' });
5041
- // ---------------------------------------------------------------
5042
- // Tool: totalreclaw_migrate
5043
- // ---------------------------------------------------------------
5044
- api.registerTool({
5045
- name: 'totalreclaw_migrate',
5046
- label: 'Migrate Testnet to Mainnet',
5047
- description: 'Migrate memories from testnet (Base Sepolia) to mainnet (Gnosis) after upgrading to Pro. ' +
5048
- 'Dry-run by default — set confirm=true to execute. Idempotent: re-running skips already-migrated facts.',
5049
- parameters: {
5050
- type: 'object',
5051
- properties: {
5052
- confirm: {
5053
- type: 'boolean',
5054
- description: 'Set to true to execute the migration. Without it, returns a dry-run preview.',
5055
- default: false,
5056
- },
5057
- },
5058
- additionalProperties: false,
5059
- },
5060
- async execute(_params) {
5061
- try {
5062
- await requireFullSetup(api.logger);
5063
- if (!authKeyHex || !subgraphOwner) {
5064
- return {
5065
- content: [{ type: 'text', text: 'Plugin not fully initialized. Ensure TOTALRECLAW_RECOVERY_PHRASE is set.' }],
5066
- };
5067
- }
5068
- if (!isSubgraphMode()) {
5069
- return {
5070
- content: [{ type: 'text', text: 'Migration is only available with the managed service (subgraph mode).' }],
5071
- };
5072
- }
5073
- const confirm = _params?.confirm === true;
5074
- const serverUrl = CONFIG.serverUrl;
5075
- // 1. Check billing tier
5076
- const billingResp = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`, {
5077
- method: 'GET',
5078
- headers: buildRelayHeaders({
5079
- 'Authorization': `Bearer ${authKeyHex}`,
5080
- 'Content-Type': 'application/json',
5081
- }),
5082
- });
5083
- if (!billingResp.ok) {
5084
- return { content: [{ type: 'text', text: `Failed to check billing tier (HTTP ${billingResp.status}).` }] };
5085
- }
5086
- const billingData = await billingResp.json();
5087
- if (billingData.tier !== 'pro') {
5088
- return {
5089
- content: [{ type: 'text', text: 'Migration requires Pro tier. Use totalreclaw_upgrade to upgrade first.' }],
5090
- };
5091
- }
5092
- // 2. Fetch testnet facts via relay (chain=testnet query param)
5093
- const testnetSubgraphUrl = `${serverUrl}/v1/subgraph?chain=testnet`;
5094
- const mainnetSubgraphUrl = `${serverUrl}/v1/subgraph`;
5095
- api.logger.info('Fetching testnet facts...');
5096
- const testnetFacts = await fetchAllFactsByOwner(testnetSubgraphUrl, subgraphOwner, authKeyHex);
5097
- if (testnetFacts.length === 0) {
5098
- return {
5099
- content: [{ type: 'text', text: 'No facts found on testnet. Nothing to migrate.' }],
5100
- };
5101
- }
5102
- // 3. Check mainnet for existing facts (idempotency)
5103
- api.logger.info('Checking mainnet for existing facts...');
5104
- const mainnetFps = await fetchContentFingerprintsByOwner(mainnetSubgraphUrl, subgraphOwner, authKeyHex);
5105
- const factsToMigrate = testnetFacts.filter(f => !f.contentFp || !mainnetFps.has(f.contentFp));
5106
- const alreadyOnMainnet = testnetFacts.length - factsToMigrate.length;
5107
- // 4. Dry-run
5108
- if (!confirm) {
5109
- const msg = factsToMigrate.length === 0
5110
- ? `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.`
5111
- : `Found ${factsToMigrate.length} facts to migrate from testnet to Gnosis mainnet (${alreadyOnMainnet} already on mainnet). Call with confirm=true to proceed.`;
5112
- return {
5113
- content: [{ type: 'text', text: msg }],
5114
- details: {
5115
- mode: 'dry_run',
5116
- testnet_facts: testnetFacts.length,
5117
- already_on_mainnet: alreadyOnMainnet,
5118
- to_migrate: factsToMigrate.length,
5119
- },
5120
- };
5121
- }
5122
- // 5. Execute migration
5123
- if (factsToMigrate.length === 0) {
5124
- return {
5125
- content: [{ type: 'text', text: `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.` }],
5126
- };
5127
- }
5128
- // Fetch blind indices
5129
- api.logger.info(`Fetching blind indices for ${factsToMigrate.length} facts...`);
5130
- const factIds = factsToMigrate.map(f => f.id);
5131
- const blindIndicesMap = await fetchBlindIndicesByFactIds(testnetSubgraphUrl, factIds, authKeyHex);
5132
- // Build protobuf payloads
5133
- const payloads = [];
5134
- for (const fact of factsToMigrate) {
5135
- const blobHex = fact.encryptedBlob.startsWith('0x') ? fact.encryptedBlob.slice(2) : fact.encryptedBlob;
5136
- const indices = blindIndicesMap.get(fact.id) || [];
5137
- const factPayload = {
5138
- id: fact.id,
5139
- timestamp: new Date().toISOString(),
5140
- owner: subgraphOwner,
5141
- encryptedBlob: blobHex,
5142
- blindIndices: indices,
5143
- decayScore: parseFloat(fact.decayScore) || 0.5,
5144
- source: fact.source || 'migration',
5145
- contentFp: fact.contentFp || '',
5146
- agentId: fact.agentId || 'openclaw-plugin',
5147
- encryptedEmbedding: fact.encryptedEmbedding || undefined,
5148
- version: PROTOBUF_VERSION_V4,
5149
- };
5150
- payloads.push(encodeFactProtobuf(factPayload));
5151
- }
5152
- // Batch submit (15 per UserOp)
5153
- const BATCH_SIZE = 15;
5154
- const batchConfig = { ...getSubgraphConfig(), authKeyHex: authKeyHex, walletAddress: subgraphOwner ?? undefined };
5155
- let migrated = 0;
5156
- let failedBatches = 0;
5157
- for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
5158
- const batch = payloads.slice(i, i + BATCH_SIZE);
5159
- const batchNum = Math.floor(i / BATCH_SIZE) + 1;
5160
- const totalBatches = Math.ceil(payloads.length / BATCH_SIZE);
5161
- api.logger.info(`Migrating batch ${batchNum}/${totalBatches} (${batch.length} facts)...`);
5162
- try {
5163
- const result = await submitFactBatchOnChain(batch, batchConfig);
5164
- if (result.success) {
5165
- migrated += batch.length;
5166
- }
5167
- else {
5168
- failedBatches++;
5169
- }
5170
- }
5171
- catch (err) {
5172
- const msg = err instanceof Error ? err.message : String(err);
5173
- api.logger.error(`Migration batch ${batchNum} failed: ${msg}`);
5174
- failedBatches++;
5175
- }
5176
- }
5177
- const resultMsg = failedBatches === 0
5178
- ? `Successfully migrated ${migrated} memories from testnet to Gnosis mainnet.`
5179
- : `Migrated ${migrated}/${factsToMigrate.length} memories. ${failedBatches} batch(es) failed — re-run to retry (idempotent).`;
5180
- return {
5181
- content: [{ type: 'text', text: resultMsg }],
5182
- details: {
5183
- mode: 'executed',
5184
- testnet_facts: testnetFacts.length,
5185
- already_on_mainnet: alreadyOnMainnet,
5186
- to_migrate: factsToMigrate.length,
5187
- migrated,
5188
- failed_batches: failedBatches,
5189
- },
5190
- };
5191
- }
5192
- catch (err) {
5193
- const message = err instanceof Error ? err.message : String(err);
5194
- api.logger.error(`totalreclaw_migrate failed: ${message}`);
5195
- return {
5196
- content: [{ type: 'text', text: `Migration failed: ${humanizeError(message)}` }],
5197
- };
5198
- }
5199
- },
5200
- }, { name: 'totalreclaw_migrate' });
5201
- // ---------------------------------------------------------------
5202
- // Tools: totalreclaw_setup + totalreclaw_onboarding_start —
5203
- // REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
5204
- // ---------------------------------------------------------------
5205
- //
5206
- // rc.4 left these two registrations in place as *neutered* stubs —
5207
- // ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
5208
- // and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
5209
- // was already pointer-only. Neither path could leak a phrase in
5210
- // rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
5211
- // regression surface: any future patch that re-enables phrase
5212
- // acceptance (e.g. a flag-driven "power-user" path) would silently
5213
- // re-open the leak, and their mere presence in the tool registry
5214
- // keeps signalling to agents that "phrase handling happens here".
5215
3874
  //
5216
- // Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
5217
- // facilitated setup surface is ``totalreclaw_pair`` (browser-side
5218
- // crypto keeps the phrase out of the LLM round-trip by construction).
5219
- // rc.5 deletes both registrations outright. The underlying CLI
5220
- // wizard (``openclaw totalreclaw onboard``) is unchanged users
5221
- // run it in their own terminal, outside any agent shell.
3875
+ // Phase 3.3: gates the bundled NATIVE memory tools (memory_search,
3876
+ // memory_get) until onboarding state is `active`. The blockReason string
3877
+ // is LLM-visible but carries no secret it's a pointer to the CLI pair
3878
+ // surface (`tr pair --url-pin`). Without this gate, an unpaired agent
3879
+ // would hit the adapter's fail-soft empty-result path and surface
3880
+ // "no memories found" with no actionable guidance.
5222
3881
  //
5223
- // Audit assertion: ``phrase-safety-registry.test.ts`` asserts
5224
- // neither name is present in the ``api.registerTool`` call list.
5225
- // Re-adding either fails CI.
5226
- //
5227
- // Historical tombstone (so LLM-assisted contributors don't re-add
5228
- // the former shape from training-data memory): rc.4 registered two
5229
- // tools by the names "totalreclaw_setup" and
5230
- // "totalreclaw_onboarding_start" as pointer-only stubs. Both were
5231
- // deleted in rc.5. Do not re-introduce.
5232
- // ---------------------------------------------------------------
5233
- // Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
5234
- //
5235
- // rc.3 shipped a `totalreclaw_onboard` agent tool that generated a
5236
- // fresh BIP-39 mnemonic in-process, wrote it to credentials.json,
5237
- // and returned `{scope_address, credentials_path}` to the agent.
5238
- // `emitPhrase: false` kept the mnemonic OUT of the tool's return
5239
- // payload, but NOTHING ARCHITECTURALLY PREVENTED leakage — a future
5240
- // patch could regress the flag, a different code path could echo
5241
- // the mnemonic in a log/error message the agent captures, or the
5242
- // mere existence of the tool implied to agents that "generating a
5243
- // phrase here is fine" (it isn't).
5244
- //
5245
- // Per ``project_phrase_safety_rule.md``
5246
- // (memory file in p-diogo/totalreclaw-internal — absolute rule:
5247
- // "recovery phrase MUST NEVER cross the LLM context in ANY form"),
5248
- // phrase-generating agent tools are forbidden. The ONLY approved
5249
- // agent-facilitated setup surface is ``totalreclaw_pair`` (browser-
5250
- // side crypto keeps the phrase out of the LLM round-trip by
5251
- // construction). The underlying ``runNonInteractiveOnboard`` code
5252
- // path is still reachable via the CLI ``openclaw totalreclaw onboard``
5253
- // — that path runs in the user's own terminal, OUTSIDE any agent
5254
- // shell, so phrase stdout never feeds back into LLM context.
5255
- //
5256
- // Audit assertion: ``tool-gating.test.ts`` enforces the removal —
5257
- // any future re-registration of ``totalreclaw_onboard`` (or any
5258
- // phrase-generating variant like ``totalreclaw_onboard_generate``,
5259
- // ``totalreclaw_restore_phrase``) fails CI.
5260
- // ---------------------------------------------------------------
5261
- // ---------------------------------------------------------------
5262
- // Tool: totalreclaw_pair (3.3.1-rc.2 — agent-callable pair-generate)
5263
- //
5264
- // Creates a pairing session (browser-mediated recovery-phrase sync),
5265
- // returns the URL + PIN + QR ASCII to the agent. The agent relays
5266
- // these to the user (paste-URL or scan-QR flow). The phrase itself
5267
- // NEVER crosses the gateway — the pair-http endpoint does the E2EE
5268
- // handshake with the browser pair-page.
5269
- // ---------------------------------------------------------------
5270
- api.registerTool({
5271
- name: 'totalreclaw_pair',
5272
- label: 'Set up the user\'s TotalReclaw account (encrypted, browser-side)',
5273
- description: 'Set up the user\'s TotalReclaw account (encrypted, browser-side recovery-phrase ' +
5274
- 'generation or import). Starts a brief remote session so the user can create or ' +
5275
- 'import a TotalReclaw recovery phrase from their phone or another browser. ' +
5276
- 'Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent ' +
5277
- 'relays to the user. The recovery phrase itself is generated/entered in the ' +
5278
- 'BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches ' +
5279
- 'the LLM provider or the chat transcript.\n\n' +
5280
- 'This is the CANONICAL agent-facilitated account-setup surface — use it whenever ' +
5281
- 'the user asks you to set up TotalReclaw, regardless of whether they have ' +
5282
- 'terminal access. Narrate it to the user as "setting up your TotalReclaw ' +
5283
- 'account". Browser-side crypto keeps the recovery phrase out of the LLM context ' +
5284
- 'entirely. If a user explicitly prefers local-terminal account setup with no ' +
5285
- 'browser, point them at `totalreclaw_onboarding_start` (a pointer to the CLI ' +
5286
- 'wizard they run on their own terminal, NOT through your shell tool).\n\n' +
5287
- 'Tool name `totalreclaw_pair` is kept for backward compatibility — function-wise ' +
5288
- 'this is the account-setup tool.',
5289
- parameters: {
5290
- type: 'object',
5291
- properties: {
5292
- mode: {
5293
- type: 'string',
5294
- enum: ['generate', 'import'],
5295
- description: '"generate" = the browser will create a NEW recovery phrase. "import" = the ' +
5296
- 'browser will accept an EXISTING phrase that the user pastes in their browser ' +
5297
- '(never through chat).',
5298
- default: 'generate',
5299
- },
5300
- },
5301
- additionalProperties: false,
5302
- },
5303
- async execute(_toolCallId, params) {
5304
- const rawMode = params?.mode;
5305
- const mode = rawMode === 'import' ? 'import' : 'generate';
5306
- const pairMode = CONFIG.pairMode;
5307
- try {
5308
- // 3.3.1-rc.11 — relay-brokered pair by default (universal reachability).
5309
- // `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
5310
- // for air-gapped / self-hosted setups. Both paths return the same
5311
- // tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
5312
- // only the URL origin differs.
5313
- let url;
5314
- let pin;
5315
- let sidOrToken;
5316
- let expiresAtMs;
5317
- let localSession;
5318
- if (pairMode === 'relay') {
5319
- const { openRemotePairSession, awaitPhraseUpload } = await import('./pair-remote-client.js');
5320
- const remoteSession = await openRemotePairSession({
5321
- relayBaseUrl: CONFIG.pairRelayUrl,
5322
- mode: mode === 'generate' ? 'generate' : 'import',
5323
- });
5324
- url = remoteSession.url;
5325
- pin = remoteSession.pin;
5326
- sidOrToken = remoteSession.token;
5327
- // Relay sends ISO-8601; convert to ms for tool payload parity.
5328
- const parsed = Date.parse(remoteSession.expiresAt);
5329
- expiresAtMs = Number.isFinite(parsed)
5330
- ? parsed
5331
- : Date.now() + 5 * 60_000;
5332
- // Background task — writes credentials.json + flips state when
5333
- // the browser completes the flow. Tool handler returns
5334
- // immediately so the agent can tell the user the URL + PIN.
5335
- //
5336
- // 3.3.4-rc.2 (Pedro QA — pair flow stuck-session) — wrap the
5337
- // WS-await in a 60s hard timeout. The relay drops sessions on
5338
- // `ws_close` (separately patched relay-side); without this
5339
- // bound the background task hangs in `waitNextMessage` for the
5340
- // full 5-minute session TTL before resolving, and downstream
5341
- // tooling that polls the gateway for completion sees stale
5342
- // `processing` state at 129s/159s/189s. 60s is the user-side
5343
- // deadline we surface in chat: long enough for a slow scan-
5344
- // and-paste, short enough that a fresh URL request is the
5345
- // obvious next step. Structured `timed_out` error in the log
5346
- // lets ops grep for the failure mode independently of generic
5347
- // ws-close errors.
5348
- const PAIR_TOOL_HARD_TIMEOUT_MS = 60_000;
5349
- void (async () => {
5350
- try {
5351
- const phraseUploadPromise = awaitPhraseUpload(remoteSession, {
5352
- phraseValidator: (p) => validateMnemonic(p, wordlist),
5353
- completePairing: async ({ mnemonic }) => {
5354
- try {
5355
- // 3.3.1 (internal#130) — derive + persist the
5356
- // Smart Account address now so the user can see
5357
- // it immediately after pair, before any chain
5358
- // write. Mnemonic stays in this scope (already
5359
- // on disk in credentials.json); only the
5360
- // derived public scope_address is added.
5361
- let scopeAddress;
5362
- try {
5363
- scopeAddress = await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
5364
- }
5365
- catch (deriveErr) {
5366
- api.logger.warn(`totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
5367
- }
5368
- const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
5369
- const next = { ...creds, mnemonic };
5370
- if (scopeAddress)
5371
- next.scope_address = scopeAddress;
5372
- if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
5373
- return { state: 'error', error: 'credentials_write_failed' };
5374
- }
5375
- setRecoveryPhraseOverride(mnemonic);
5376
- writeOnboardingState(CONFIG.onboardingStatePath, {
5377
- onboardingState: 'active',
5378
- createdBy: mode === 'generate' ? 'generate' : 'import',
5379
- credentialsCreatedAt: new Date().toISOString(),
5380
- version: pluginVersion ?? '3.3.0',
5381
- });
5382
- // 3.3.13 — sentinel cleanup; see auto-pair-on-load.ts.
5383
- deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
5384
- api.logger.info(`totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`);
5385
- return { state: 'active' };
5386
- }
5387
- catch (err) {
5388
- const msg = err instanceof Error ? err.message : String(err);
5389
- api.logger.error(`totalreclaw_pair(relay): completePairing failed: ${msg}`);
5390
- return { state: 'error', error: msg };
5391
- }
5392
- },
5393
- // 3.3.4-rc.2 — also pass through to awaitPhraseUpload so its
5394
- // internal `waitNextMessage` timer matches the outer race.
5395
- timeoutMs: PAIR_TOOL_HARD_TIMEOUT_MS,
5396
- });
5397
- // 3.3.4-rc.2 — outer Promise.race guard. Resolves to a
5398
- // sentinel ({ status: 'timed_out', ... }) so the catch
5399
- // handler can distinguish a hard-timeout from a generic
5400
- // ws-close error and surface it explicitly.
5401
- const TIMEOUT_SENTINEL = {
5402
- status: 'timed_out',
5403
- message: `Pair flow timed out (${PAIR_TOOL_HARD_TIMEOUT_MS / 1000}s) — generate a new URL with totalreclaw_pair.`,
5404
- };
5405
- let hardTimer;
5406
- const hardTimeoutPromise = new Promise((resolve) => {
5407
- hardTimer = setTimeout(() => resolve(TIMEOUT_SENTINEL), PAIR_TOOL_HARD_TIMEOUT_MS);
5408
- });
5409
- try {
5410
- const raced = await Promise.race([
5411
- phraseUploadPromise,
5412
- hardTimeoutPromise,
5413
- ]);
5414
- if (raced &&
5415
- typeof raced === 'object' &&
5416
- raced.status === 'timed_out') {
5417
- api.logger.warn(`totalreclaw_pair(relay): hard timeout — ${raced.message} (token=${remoteSession.token.slice(0, 8)}…)`);
5418
- }
5419
- }
5420
- finally {
5421
- if (hardTimer)
5422
- clearTimeout(hardTimer);
5423
- }
5424
- }
5425
- catch (bgErr) {
5426
- // Expected on TTL expiry / user-aborts — log at warn, not error.
5427
- const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
5428
- api.logger.warn(`totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`);
5429
- }
5430
- })();
5431
- }
5432
- else {
5433
- // Local loopback path (rc.10 behaviour).
5434
- const { createPairSession } = await import('./pair-session-store.js');
5435
- const { generateGatewayKeypair } = await import('./pair-crypto.js');
5436
- const kp = generateGatewayKeypair();
5437
- const session = await createPairSession(CONFIG.pairSessionsPath, {
5438
- mode,
5439
- operatorContext: { channel: 'agent' },
5440
- rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5441
- rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5442
- });
5443
- url = buildPairingUrl(api, session);
5444
- pin = session.secondaryCode;
5445
- sidOrToken = session.sid;
5446
- expiresAtMs = session.expiresAtMs;
5447
- localSession = session;
5448
- }
5449
- // QR renderers — same for both modes; input is the URL string.
5450
- const { defaultRenderQr } = await import('./pair-cli.js');
5451
- const qrAscii = await new Promise((resolve) => {
5452
- let settled = false;
5453
- const t = setTimeout(() => {
5454
- if (!settled) {
5455
- settled = true;
5456
- resolve('');
5457
- }
5458
- }, 5_000);
5459
- try {
5460
- defaultRenderQr(url, (ascii) => {
5461
- if (settled)
5462
- return;
5463
- settled = true;
5464
- clearTimeout(t);
5465
- resolve(ascii);
5466
- });
5467
- }
5468
- catch {
5469
- if (settled)
5470
- return;
5471
- settled = true;
5472
- clearTimeout(t);
5473
- resolve('');
5474
- }
5475
- });
5476
- // 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
5477
- let qrPngB64 = '';
5478
- let qrUnicode = '';
5479
- try {
5480
- const { encodePng, encodeUnicode } = await import('./pair-qr.js');
5481
- const [pngBuf, uni] = await Promise.all([
5482
- encodePng(url),
5483
- encodeUnicode(url),
5484
- ]);
5485
- qrPngB64 = pngBuf.toString('base64');
5486
- qrUnicode = uni;
5487
- }
5488
- catch (qrErr) {
5489
- api.logger.warn(`totalreclaw_pair: QR encode failed (non-fatal): ${qrErr instanceof Error ? qrErr.message : String(qrErr)}`);
5490
- }
5491
- api.logger.info(`totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`);
5492
- // Voidly reference localSession so TS does not flag the unused
5493
- // local-branch binding. Future rc.12 diagnostics can expose
5494
- // `session.mode` / `session.status` separately.
5495
- void localSession;
5496
- return {
5497
- content: [{
5498
- type: 'text',
5499
- text: `Pairing session started.\n\n` +
5500
- `URL: ${url}\n\n` +
5501
- `PIN (type this into the browser): ${pin}\n\n` +
5502
- (qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
5503
- `Instructions for the user:\n` +
5504
- `1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
5505
- `2. ` +
5506
- (mode === 'generate'
5507
- ? `The browser will generate a NEW 12-word recovery phrase and ask the user to write it down + retype 3 words before finalizing.`
5508
- : `The browser will accept an EXISTING phrase that the user pastes in the browser (never through chat).`) +
5509
- `\n3. Enter the 6-digit PIN shown above into the browser.\n` +
5510
- `4. The encrypted phrase uploads to this gateway — it NEVER touches the LLM.\n` +
5511
- `5. Come back to chat once the browser says "Pairing complete".\n\n` +
5512
- `This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
5513
- }],
5514
- details: {
5515
- sid: sidOrToken,
5516
- url,
5517
- pin,
5518
- mode,
5519
- expires_at_ms: expiresAtMs,
5520
- qr_ascii: qrAscii,
5521
- qr_png_b64: qrPngB64,
5522
- qr_unicode: qrUnicode,
5523
- // rc.11 — surface the transport so downstream tooling (QA
5524
- // harness asserters, telemetry) can confirm which path
5525
- // served the URL. Either 'relay' or 'local'.
5526
- transport: pairMode,
5527
- },
5528
- };
5529
- }
5530
- catch (err) {
5531
- const message = err instanceof Error ? err.message : String(err);
5532
- api.logger.error(`totalreclaw_pair failed: ${message}`);
5533
- return {
5534
- content: [{ type: 'text', text: `Failed to start pairing session: ${humanizeError(message)}` }],
5535
- details: { error: message },
5536
- };
5537
- }
5538
- },
5539
- }, { name: 'totalreclaw_pair' });
5540
- // 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
5541
- // ops/QA can grep gateway logs for definitive proof the tool was
5542
- // declared. If the agent then reports the tool is missing from its
5543
- // tool list, the gap is upstream OpenClaw tool propagation, not our
5544
- // plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
5545
- //
5546
- // 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
5547
- // bleeding into `openclaw agent --json` stdout, breaking programmatic
5548
- // parsers that expect the JSON-RPC body to be the only thing on the
5549
- // wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
5550
- // `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
5551
- // a tool-injection regression. Default OFF — clean stdout for users.
5552
- if (CONFIG.verboseRegister) {
5553
- api.logger.info('TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
5554
- 'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
5555
- '`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.');
5556
- }
5557
- // ---------------------------------------------------------------
5558
- // Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
5559
- //
5560
- // Lets the agent file a structured QA-bug issue to
5561
- // `p-diogo/totalreclaw-internal` during RC testing. Only registered
5562
- // when the plugin version contains `-rc.` — stable users never see it.
5563
- //
5564
- // Secrets (recovery phrases, API keys, Telegram bot tokens) are
5565
- // redacted inside `postQaBugIssue` before the POST. The agent should
5566
- // still avoid passing raw secrets — see SKILL.md addendum.
5567
- // ---------------------------------------------------------------
5568
- if (rcMode) {
5569
- api.registerTool({
5570
- name: 'totalreclaw_report_qa_bug',
5571
- label: 'File a QA bug issue (RC builds only)',
5572
- description: 'File a structured QA bug report to the internal tracker. RC-only; never available in stable builds. ' +
5573
- 'Do NOT call auto-file — ask the user first before invoking. The tool redacts recovery phrases, API keys, ' +
5574
- 'and Telegram bot tokens from all free-text fields before posting, but the agent SHOULD still avoid ' +
5575
- 'passing raw secrets.',
5576
- parameters: {
5577
- type: 'object',
5578
- properties: {
5579
- integration: {
5580
- type: 'string',
5581
- enum: ['plugin', 'hermes', 'nanoclaw', 'mcp', 'relay', 'clawhub', 'docs', 'other'],
5582
- description: 'Which TotalReclaw surface is affected.',
5583
- },
5584
- rc_version: {
5585
- type: 'string',
5586
- description: 'Exact RC version string (e.g. "3.3.1-rc.3" or "2.3.1rc3").',
5587
- },
5588
- severity: {
5589
- type: 'string',
5590
- enum: ['blocker', 'high', 'medium', 'low'],
5591
- description: 'blocker=release blocked, high=major UX failure, medium=annoying, low=polish.',
5592
- },
5593
- title: {
5594
- type: 'string',
5595
- description: 'Short summary, <60 chars. Prefix "[qa-bug]" is added automatically.',
5596
- maxLength: 60,
5597
- },
5598
- symptom: {
5599
- type: 'string',
5600
- description: 'What happened (redacted automatically).',
5601
- },
5602
- expected: {
5603
- type: 'string',
5604
- description: 'What should have happened.',
5605
- },
5606
- repro: {
5607
- type: 'string',
5608
- description: 'Reproduction steps (redacted automatically).',
5609
- },
5610
- logs: {
5611
- type: 'string',
5612
- description: 'Log excerpts / error messages (redacted automatically).',
5613
- },
5614
- environment: {
5615
- type: 'string',
5616
- description: 'Host, Docker/native, OpenClaw version, LLM provider, etc.',
5617
- },
5618
- },
5619
- required: [
5620
- 'integration',
5621
- 'rc_version',
5622
- 'severity',
5623
- 'title',
5624
- 'symptom',
5625
- 'expected',
5626
- 'repro',
5627
- 'logs',
5628
- 'environment',
5629
- ],
5630
- additionalProperties: false,
5631
- },
5632
- async execute(_toolCallId, params) {
5633
- try {
5634
- const { postQaBugIssue } = await import('./qa-bug-report.js');
5635
- // The token is resolved via CONFIG (config.ts) so index.ts
5636
- // stays clean of env-harvesting triggers.
5637
- const token = CONFIG.qaGithubToken;
5638
- if (!token) {
5639
- return {
5640
- content: [{
5641
- type: 'text',
5642
- text: 'Cannot file QA bug: no GitHub token found. The operator must export ' +
5643
- 'TOTALRECLAW_QA_GITHUB_TOKEN (or GITHUB_TOKEN) with `repo` scope to enable ' +
5644
- 'agent-filed bug reports during RC testing.',
5645
- }],
5646
- details: { error: 'missing_github_token' },
5647
- };
5648
- }
5649
- // rc.14 — `repo` is resolved inside `postQaBugIssue` via
5650
- // `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
5651
- // refuses any slug that isn't a `-internal` fork. Pass the
5652
- // config-surfaced override so env reads stay in config.ts.
5653
- const repoOverride = CONFIG.qaRepoOverride || undefined;
5654
- const result = await postQaBugIssue(params, {
5655
- githubToken: token,
5656
- repo: repoOverride,
5657
- logger: api.logger,
5658
- });
5659
- return {
5660
- content: [{
5661
- type: 'text',
5662
- text: `Filed QA bug #${result.issue_number}: ${result.issue_url}`,
5663
- }],
5664
- details: { issue_url: result.issue_url, issue_number: result.issue_number },
5665
- };
5666
- }
5667
- catch (err) {
5668
- const message = err instanceof Error ? err.message : String(err);
5669
- api.logger.error(`totalreclaw_report_qa_bug failed: ${message}`);
5670
- return {
5671
- content: [{
5672
- type: 'text',
5673
- text: `Failed to file QA bug: ${message}`,
5674
- }],
5675
- details: { error: message },
5676
- };
5677
- }
5678
- },
5679
- }, { name: 'totalreclaw_report_qa_bug' });
5680
- // 3.3.1-rc.21 (issue #128): demote the registration-confirmation
5681
- // breadcrumb to verbose-only. Same `--json` stdout pollution risk
5682
- // as the totalreclaw_pair breadcrumb above; ops can opt back in
5683
- // via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
5684
- if (CONFIG.verboseRegister) {
5685
- api.logger.info('totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).');
5686
- }
5687
- }
5688
- // ---------------------------------------------------------------
5689
- // Hook: before_tool_call (3.2.0 memory-tool gate)
5690
- // ---------------------------------------------------------------
5691
- //
5692
- // Blocks every memory tool until onboarding state is `active`. The
5693
- // `blockReason` string is LLM-visible but carries no secret — it's a
5694
- // pointer to the CLI wizard.
5695
- //
5696
- // Non-gated tools: totalreclaw_upgrade, totalreclaw_migrate,
5697
- // totalreclaw_onboarding_start, totalreclaw_setup (deprecated).
5698
- // Billing tools work pre-onboarding because they help the user reach a
5699
- // Pro tier before they have memories to store; setup-adjacent tools
5700
- // return their own routing messages.
3882
+ // Non-gated tools: every other tool the agent sees (read/write helpers,
3883
+ // the host's own tools, etc.). The pair surface is intentionally not
3884
+ // gated users must be able to start onboarding before the vault is
3885
+ // active.
5701
3886
  //
5702
3887
  // Decision logic lives in `tool-gating.ts` so it's unit-testable
5703
3888
  // without a full plugin host.
@@ -5848,18 +4033,18 @@ const plugin = {
5848
4033
  '## TotalReclaw setup pending\n\n' +
5849
4034
  'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
5850
4035
  'If the user asks about memory features or wants to configure TotalReclaw, ' +
5851
- 'call the `totalreclaw_pair` tool it returns a pairing URL + 6-digit PIN ' +
5852
- 'the user opens in a browser (phrase is generated and encrypted in-browser, ' +
5853
- 'never leaves their device in plaintext). If `totalreclaw_pair` is not in ' +
5854
- 'your tool list (container-based agents sometimes miss it), shell out to ' +
5855
- '`openclaw totalreclaw pair generate --url-pin-only` on the gateway host ' +
5856
- 'it emits a single JSON line `{v,url,pin,expires_at_ms}` with no phrase ' +
5857
- 'material; hand the user the `url` and `pin`.\n\n' +
4036
+ 'pairing is QR-only and CLI-driven (there is no agent tool for it). Run ' +
4037
+ '`tr pair --url-pin` in a shell on the gateway host it emits a single JSON ' +
4038
+ 'line `{v,url,pin,expires_at_ms}` with no phrase material. Hand the user the ' +
4039
+ '`url` and `pin`; they open the URL in a browser where the recovery phrase is ' +
4040
+ 'generated and encrypted in-browser and never leaves their device in plaintext. ' +
4041
+ 'Once pairing completes, memory_search/memory_get unlock automatically and this ' +
4042
+ 'banner stops appearing.\n\n' +
5858
4043
  '**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
5859
4044
  '**Do NOT** run `openclaw totalreclaw onboard` — that CLI emits the recovery ' +
5860
- 'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
5861
- '(tool or `--url-pin-only` CLI) instead; `onboard` is reserved for users ' +
5862
- 'running it directly in their own local terminal.',
4045
+ 'phrase on stdout and would leak it into the LLM transcript. Use `tr pair --url-pin` ' +
4046
+ '(or `openclaw totalreclaw pair generate --url-pin-only`) instead; `onboard` is ' +
4047
+ 'reserved for users running it directly in their own local terminal.',
5863
4048
  };
5864
4049
  }
5865
4050
  // One-time welcome message (first conversation after setup or returning user)
@@ -5875,7 +4060,7 @@ const plugin = {
5875
4060
  const tier = cache?.tier || 'free';
5876
4061
  const tierInfo = tier === 'pro'
5877
4062
  ? 'You are on the **Pro** tier — unlimited memories, permanently stored on Gnosis mainnet.'
5878
- : 'You are on the **Free** tier — memories stored on testnet. Use the totalreclaw_upgrade tool to upgrade to Pro for permanent on-chain storage.';
4063
+ : 'You are on the **Free** tier — memories stored on testnet. (Upgrade to Pro: run `openclaw totalreclaw upgrade` on the gateway host for a Stripe checkout URL.)';
5879
4064
  welcomeBack = `\n\nTotalReclaw is active. I will automatically remember important things from our conversations and recall relevant context at the start of each session. ${tierInfo}`;
5880
4065
  }
5881
4066
  // Billing cache check — warn if quota is approaching limit.
@@ -5897,6 +4082,10 @@ const plugin = {
5897
4082
  free_writes_used: billingData.free_writes_used ?? 0,
5898
4083
  free_writes_limit: billingData.free_writes_limit ?? 0,
5899
4084
  features: billingData.features,
4085
+ // Relay's authoritative chain_id → drives the chain override verbatim (#402).
4086
+ chain_id: billingData.chain_id,
4087
+ // Relay's authoritative data_edge_address → drives the DataEdge override verbatim (#460).
4088
+ data_edge_address: billingData.data_edge_address,
5900
4089
  checked_at: Date.now(),
5901
4090
  };
5902
4091
  writeBillingCache(cache);
@@ -5905,7 +4094,7 @@ const plugin = {
5905
4094
  if (cache && cache.free_writes_limit > 0) {
5906
4095
  const usageRatio = cache.free_writes_used / cache.free_writes_limit;
5907
4096
  if (usageRatio >= QUOTA_WARNING_THRESHOLD) {
5908
- billingWarning = `\n\nTotalReclaw quota warning: ${cache.free_writes_used}/${cache.free_writes_limit} writes used this month (${Math.round(usageRatio * 100)}%). Visit https://totalreclaw.xyz/pricing to upgrade.`;
4097
+ billingWarning = `\n\nTotalReclaw quota warning: ${cache.free_writes_used}/${cache.free_writes_limit} memories used this month (${Math.round(usageRatio * 100)}%). Visit https://totalreclaw.xyz/pricing to upgrade.`;
5909
4098
  }
5910
4099
  }
5911
4100
  }
@@ -6076,7 +4265,7 @@ const plugin = {
6076
4265
  // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
6077
4266
  const hookQueryIntent = detectQueryIntent(evt.prompt);
6078
4267
  const reranked = rerank(evt.prompt, queryEmbedding ?? [], rerankerCandidates, 8, INTENT_WEIGHTS[hookQueryIntent],
6079
- /* applySourceWeights (Retrieval v2 Tier 1) */ true);
4268
+ /* applySourceWeights (Retrieval v2 Tier 1) */ false);
6080
4269
  // Update hot cache with reranked results.
6081
4270
  try {
6082
4271
  if (pluginHotCache) {
@@ -6098,15 +4287,16 @@ const plugin = {
6098
4287
  if (reranked.length === 0)
6099
4288
  return undefined;
6100
4289
  // Relevance gate removed in rc.22 (see recall tool comment).
6101
- // 7. Build context string.
6102
- const lines = reranked.map((m, i) => {
4290
+ // 7. Build context string using core's unified recall formatter (adds dates + header).
4291
+ const recallItems = reranked.map((m) => {
6103
4292
  const meta = hookMetaMap.get(m.id);
6104
- const importance = meta?.importance ?? 5;
6105
- const age = meta?.age ?? '';
6106
- const typeTag = meta?.category ? `[${meta.category}] ` : '';
6107
- return `${i + 1}. ${typeTag}${m.text} (importance: ${importance}/10, ${age})`;
4293
+ return {
4294
+ category: meta?.category ?? 'claim',
4295
+ text: m.text,
4296
+ created_at: m.createdAt ?? 0,
4297
+ };
6108
4298
  });
6109
- const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
4299
+ const contextString = getSmartImportWasm().formatRecallContext(JSON.stringify(recallItems), BigInt(Math.floor(Date.now() / 1000)));
6110
4300
  return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
6111
4301
  }
6112
4302
  // --- Server mode (existing behavior) ---
@@ -6177,18 +4367,18 @@ const plugin = {
6177
4367
  // 6. Re-rank with BM25 + cosine + RRF fusion (intent-weighted).
6178
4368
  const srvHookIntent = detectQueryIntent(evt.prompt);
6179
4369
  const reranked = rerank(evt.prompt, queryEmbedding ?? [], rerankerCandidates, 8, INTENT_WEIGHTS[srvHookIntent],
6180
- /* applySourceWeights (Retrieval v2 Tier 1) */ true);
4370
+ /* applySourceWeights (Retrieval v2 Tier 1) */ false);
6181
4371
  if (reranked.length === 0)
6182
4372
  return undefined;
6183
4373
  // Relevance gate removed in rc.22 (see recall tool comment).
6184
- // 7. Build context string.
6185
- const lines = reranked.map((m, i) => {
6186
- const meta = hookMetaMap.get(m.id);
6187
- const importance = meta?.importance ?? 5;
6188
- const age = meta?.age ?? '';
6189
- return `${i + 1}. ${m.text} (importance: ${importance}/10, ${age})`;
6190
- });
6191
- const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
4374
+ // 7. Build context string using core's unified recall formatter (adds dates + header).
4375
+ // Server mode has no category metadata, so we use 'claim' as default.
4376
+ const srvRecallItems = reranked.map((m) => ({
4377
+ category: 'claim',
4378
+ text: m.text,
4379
+ created_at: m.createdAt ?? 0,
4380
+ }));
4381
+ const contextString = getSmartImportWasm().formatRecallContext(JSON.stringify(srvRecallItems), BigInt(Math.floor(Date.now() / 1000)));
6192
4382
  return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
6193
4383
  }
6194
4384
  catch (err) {
@@ -6285,6 +4475,14 @@ const plugin = {
6285
4475
  // surface (scanner constraint: a single file may not contain both
6286
4476
  // fs.read* AND outbound-request trigger words). Deps are passed in
6287
4477
  // here with neutral aliases for the same reason.
4478
+ //
4479
+ // Lifecycle (rc.20, #402): register() can run more than once per process
4480
+ // (OpenClaw's SIGUSR1 restarts are IN-PROCESS, so the module cache and any
4481
+ // running poller survive). No guard is needed here — startTrajectoryPoller
4482
+ // holds a module-global singleton and stops the previous poller before
4483
+ // starting a new one, and each tick self-terminates if the plugin's own
4484
+ // module file is gone (uninstalled/replaced). This prevents the poller
4485
+ // accumulation + zombie-old-version submitters seen on pop-os.
6288
4486
  // ---------------------------------------------------------------
6289
4487
  startTrajectoryPoller({
6290
4488
  logger: api.logger,
@@ -6406,63 +4604,98 @@ const plugin = {
6406
4604
  }
6407
4605
  }, { priority: 5 });
6408
4606
  // ---------------------------------------------------------------
6409
- // 3.3.2-rc.1 (issue #186) — write `.loaded.json` manifest
4607
+ // OpenClaw native memory integration (Task 2.7) — register TR as the
4608
+ // memory backend: the MemoryPluginCapability + the memory_search /
4609
+ // memory_get tools the active-memory sub-agent drives.
6410
4610
  // ---------------------------------------------------------------
6411
4611
  //
6412
- // Final step of register(): drop the success manifest so the agent
6413
- // can `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` to
6414
- // verify which tools bound. Synchronous (see writePluginManifest doc).
6415
- // Never throws diagnostic loss only on I/O failure.
6416
- if (_pluginDirForManifest) {
6417
- try {
6418
- const ok = writePluginManifest(_pluginDirForManifest, {
6419
- loadedAt: Date.now(),
6420
- tools: _registeredToolNames.slice(),
6421
- version: pluginVersion ?? 'unknown',
6422
- // 3.3.8-rc.1 hybrid mode annotation: tools ARE registered with the
6423
- // SDK (required for plugin loader validation), but tool calls are
6424
- // dead-letter on OC 2026.5.2 due to issue #223. Use `tr <cmd>` CLI.
6425
- hybridMode: true,
6426
- hybridCliTools: ['tr status', 'tr pair', 'tr remember', 'tr recall'],
6427
- });
6428
- if (ok) {
6429
- api.logger.info(`TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools + hybridCli=tr, version=${pluginVersion ?? 'unknown'})`);
6430
- }
6431
- }
6432
- catch {
6433
- // Best-effort; helper swallows internally too.
6434
- }
4612
+ // This is THE integration point. For TR to BE the memory backend (not
4613
+ // just a tool plugin), it must register all four against TR's own
4614
+ // pipeline:
4615
+ // 1. api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })
4616
+ // 2. api.registerTool(() => createMemorySearchTool(runtime), { names: ['memory_search'] })
4617
+ // 3. api.registerTool(() => createMemoryGetTool(runtime), { names: ['memory_get'] })
4618
+ //
4619
+ // These registerTool calls go through the real host api.registerTool
4620
+ // directly (the 3.3.2-rc.1 monkey-patch + .loaded.json manifest
4621
+ // machinery were removed in Phase 3 — the conventional names survive
4622
+ // the tool-policy strip in OC 2026.5.x, so the declare-and-dead-letter
4623
+ // dance + manifest are obsolete).
4624
+ //
4625
+ // Deps: buildRecallDeps captures `api.logger` so the closures can call
4626
+ // ensureInitialized lazily on first use. The paired-account context
4627
+ // (authKeyHex / encryptionKey / userId / subgraphOwner) is NOT
4628
+ // resolved here — it's populated by initialize() on the first tool
4629
+ // call. The closures call ensureInitialized internally (see
4630
+ // buildRecallDeps docstring).
4631
+ //
4632
+ // Scanner note: this call is fine inside register() because
4633
+ // register() itself is not scanner-clean (the file pairs config reads
4634
+ // with network calls legitimately). The scanner-clean surface is
4635
+ // native-memory.ts (the wiring helper), which never touches env or
4636
+ // net primitives.
4637
+ //
4638
+ // Graceful degradation: the wiring is wrapped in try/catch so a
4639
+ // failure in the native memory pipeline cannot block plugin load.
4640
+ // NOTE (Phase 3.3): the legacy totalreclaw_* agent tools that used to
4641
+ // serve as the capture fallback were RETIRED in Task 3.2. If this
4642
+ // registration fails, the agent has NO memory surface until the cause
4643
+ // is fixed and the gateway restarted. The before_tool_call gate stays
4644
+ // armed (memory_search/memory_get are simply never registered), and
4645
+ // auto-extraction hooks still fire on the message_received / agent_end
4646
+ // cadence — they write to the subgraph directly, so memories keep
4647
+ // getting captured even if the agent can't read them mid-session.
4648
+ try {
4649
+ registerNativeMemory(api, buildRecallDeps(api.logger));
4650
+ api.logger.info('TotalReclaw: registered native MemoryPluginCapability + memory_search/memory_get tools');
4651
+ }
4652
+ catch (err) {
4653
+ const msg = err instanceof Error ? err.message : String(err);
4654
+ api.logger.warn(`TotalReclaw: native memory capability registration failed — agent memory_search/memory_get UNAVAILABLE until fixed: ${msg}`);
4655
+ }
4656
+ // ---------------------------------------------------------------
4657
+ // Skill auto-register (rc.17 QA finding: plugin installs but the
4658
+ // SKILL.md playbook does not — agents skipped the separate
4659
+ // `openclaw skills install totalreclaw` step and ended up without
4660
+ // pairing / recall instructions). Mirror the bundled SKILL.md +
4661
+ // skill.json from the package root into the workspace skills dir so
4662
+ // OpenClaw's workspace skill scanner discovers them on the next
4663
+ // gateway load. A single `openclaw plugins install` is now enough
4664
+ // for both plugin + skill. Idempotent + never throws (see
4665
+ // skill-register.ts). Lives in a scanner-clean helper because
4666
+ // index.ts already pairs env-derived config with network calls, so
4667
+ // the disk I/O must stay out of this file.
4668
+ // ---------------------------------------------------------------
4669
+ try {
4670
+ // Re-resolve the dist dir here: the earlier `pluginDir` const
4671
+ // lives inside its own inner try/catch scope and is not visible
4672
+ // this far down. The call is pure + cheap (URL parse + dirname).
4673
+ ensureSkillRegistered({
4674
+ pluginDir: nodePath.dirname(fileURLToPath(import.meta.url)),
4675
+ skillsDir: nodePath.join(CONFIG.openclawWorkspace, 'skills'),
4676
+ logger: api.logger,
4677
+ });
4678
+ }
4679
+ catch (err) {
4680
+ const msg = err instanceof Error ? err.message : String(err);
4681
+ api.logger.warn(`TotalReclaw: skill auto-register failed (non-fatal): ${msg}`);
6435
4682
  }
6436
4683
  }
6437
4684
  catch (registerErr) {
6438
4685
  // ---------------------------------------------------------------
6439
- // 3.3.2-rc.1 (issue #186) — write `.error.json` on register() throw
4686
+ // register() threw best-effort log then re-throw so the SDK sees
4687
+ // the original failure. (3.3.2-rc.1 used to write a `.error.json`
4688
+ // marker here; that machinery was retired in Phase 3.4 along with
4689
+ // the `.loaded.json` success manifest. The gateway log is now the
4690
+ // source of truth for register() failures.)
6440
4691
  // ---------------------------------------------------------------
6441
- //
6442
- // Some surface threw out of register(). Drop a structured error
6443
- // marker the agent can grep. Best-effort logging then re-throw so
6444
- // the SDK sees the original failure.
6445
4692
  const errMsg = registerErr instanceof Error ? registerErr.message : String(registerErr);
6446
- const errStack = registerErr instanceof Error ? registerErr.stack : undefined;
6447
4693
  try {
6448
4694
  api.logger.error(`TotalReclaw: register() threw: ${errMsg}`);
6449
4695
  }
6450
4696
  catch {
6451
4697
  // Logger may be unavailable (very early failure path).
6452
4698
  }
6453
- if (_pluginDirForManifest) {
6454
- try {
6455
- writePluginError(_pluginDirForManifest, {
6456
- loadedAt: Date.now(),
6457
- error: errMsg,
6458
- stack: errStack,
6459
- version: 'unknown',
6460
- });
6461
- }
6462
- catch {
6463
- // Best-effort.
6464
- }
6465
- }
6466
4699
  throw registerErr;
6467
4700
  }
6468
4701
  },