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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +34 -249
  3. package/api-client.ts +17 -9
  4. package/batch-gate.ts +42 -0
  5. package/billing-cache.ts +25 -20
  6. package/claims-helper.ts +7 -1
  7. package/config.ts +54 -12
  8. package/consolidation.ts +6 -13
  9. package/contradiction-sync.ts +19 -14
  10. package/credential-provider.ts +184 -0
  11. package/crypto.ts +27 -160
  12. package/dist/api-client.js +17 -9
  13. package/dist/batch-gate.js +40 -0
  14. package/dist/billing-cache.js +19 -21
  15. package/dist/claims-helper.js +7 -1
  16. package/dist/config.js +54 -12
  17. package/dist/consolidation.js +6 -15
  18. package/dist/contradiction-sync.js +15 -15
  19. package/dist/credential-provider.js +145 -0
  20. package/dist/crypto.js +17 -137
  21. package/dist/download-ux.js +11 -7
  22. package/dist/embedder-loader.js +266 -0
  23. package/dist/embedding.js +36 -3
  24. package/dist/entry.js +123 -0
  25. package/dist/extractor.js +134 -0
  26. package/dist/fs-helpers.js +116 -241
  27. package/dist/import-adapters/chatgpt-adapter.js +14 -0
  28. package/dist/import-adapters/claude-adapter.js +14 -0
  29. package/dist/import-adapters/gemini-adapter.js +43 -159
  30. package/dist/import-adapters/mcp-memory-adapter.js +14 -0
  31. package/dist/import-state-manager.js +100 -0
  32. package/dist/index.js +1130 -2520
  33. package/dist/llm-client.js +69 -1
  34. package/dist/memory-runtime.js +459 -0
  35. package/dist/native-memory.js +123 -0
  36. package/dist/onboarding-cli.js +3 -2
  37. package/dist/pair-cli.js +1 -1
  38. package/dist/pair-crypto.js +16 -358
  39. package/dist/pair-http.js +147 -4
  40. package/dist/relay.js +140 -0
  41. package/dist/reranker.js +13 -8
  42. package/dist/semantic-dedup.js +5 -7
  43. package/dist/skill-register.js +97 -0
  44. package/dist/subgraph-search.js +3 -1
  45. package/dist/subgraph-store.js +315 -282
  46. package/dist/tool-gating.js +39 -26
  47. package/dist/tools.js +367 -0
  48. package/dist/tr-cli-export-helper.js +103 -0
  49. package/dist/tr-cli.js +220 -127
  50. package/dist/trajectory-poller.js +155 -9
  51. package/dist/vault-crypto.js +551 -0
  52. package/download-ux.ts +12 -6
  53. package/embedder-loader.ts +293 -1
  54. package/embedding.ts +43 -3
  55. package/entry.ts +132 -0
  56. package/extractor.ts +167 -0
  57. package/fs-helpers.ts +166 -292
  58. package/import-adapters/chatgpt-adapter.ts +18 -0
  59. package/import-adapters/claude-adapter.ts +18 -0
  60. package/import-adapters/gemini-adapter.ts +56 -183
  61. package/import-adapters/mcp-memory-adapter.ts +18 -0
  62. package/import-adapters/types.ts +1 -1
  63. package/import-state-manager.ts +139 -0
  64. package/index.ts +1432 -3002
  65. package/llm-client.ts +74 -1
  66. package/memory-runtime.ts +723 -0
  67. package/native-memory.ts +196 -0
  68. package/onboarding-cli.ts +3 -2
  69. package/openclaw.plugin.json +5 -17
  70. package/package.json +7 -4
  71. package/pair-cli.ts +1 -1
  72. package/pair-crypto.ts +41 -483
  73. package/pair-http.ts +194 -5
  74. package/postinstall.mjs +138 -0
  75. package/relay.ts +172 -0
  76. package/reranker.ts +13 -8
  77. package/semantic-dedup.ts +5 -6
  78. package/skill-register.ts +146 -0
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +3 -1
  81. package/subgraph-store.ts +334 -299
  82. package/tool-gating.ts +39 -26
  83. package/tools.ts +499 -0
  84. package/tr-cli-export-helper.ts +138 -0
  85. package/tr-cli.ts +263 -133
  86. package/trajectory-poller.ts +162 -10
  87. package/vault-crypto.ts +705 -0
package/dist/index.js CHANGED
@@ -8,35 +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_upgrade -- create Stripe checkout for Pro upgrade
22
- * - totalreclaw_migrate -- migrate testnet memories to mainnet after Pro upgrade
23
- * - totalreclaw_onboarding_start -- non-secret pointer to the CLI wizard (3.2.0)
24
- * - 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).
25
47
  *
26
48
  * Also registers:
27
- * - `before_agent_start` hook that automatically injects relevant memories
28
- * into the agent's context (and a non-secret onboarding hint when the
29
- * user has not completed the CLI setup yet).
30
- * - `before_tool_call` hook that gates every memory tool until onboarding
31
- * state is `active` (3.2.0).
32
- * - `registerCli` subcommand `openclaw totalreclaw onboard` the ONLY
33
- * surface that generates or accepts a recovery phrase. Lives entirely on
34
- * the user's terminal; the phrase never enters an LLM request or a
35
- * 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).
36
56
  * - `registerCommand` slash command `/totalreclaw {onboard,status}` — a
37
- * non-secret pointer that directs the user to the CLI wizard.
57
+ * non-secret pointer to the CLI wizard.
38
58
  *
39
- * Security: in 3.2.0, the recovery phrase NEVER appears in tool responses,
59
+ * Security: the recovery phrase NEVER appears in tool responses,
40
60
  * `prependContext` blocks, slash-command replies, or any other surface that
41
61
  * is sent to the LLM provider or persisted to the session transcript. See
42
62
  * `docs/plans/2026-04-20-plugin-320-secure-onboarding.md` in the internal
@@ -47,27 +67,24 @@
47
67
  */
48
68
  import { deriveKeys, deriveLshSeed, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
49
69
  import { createApiClient } from './api-client.js';
50
- import { extractFacts, extractDebrief, 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';
51
71
  import { initLLMClient, resolveLLMConfig, chatCompletion, generateEmbedding, getEmbeddingDims, getEmbeddingModelId, configureEmbedder, prefetchEmbedderBundle, } from './llm-client.js';
52
72
  import { defaultAuthProfilesRoot, readAllProfileKeys, dedupeByProvider, } from './llm-profile-reader.js';
53
73
  import { LSHHasher } from './lsh.js';
54
74
  import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS } from './reranker.js';
55
75
  import { deduplicateBatch } from './semantic-dedup.js';
56
76
  import { startTrajectoryPoller } from './trajectory-poller.js';
57
- 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';
58
78
  import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4 } from './subgraph-store.js';
59
- import { confirmIndexed } from './confirm-indexed.js';
60
- 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';
61
80
  import { maybeInjectDigest, recompileDigest, fetchAllActiveClaims, isRecompileInProgress, tryBeginRecompile, endRecompile, } from './digest-sync.js';
62
81
  import { detectAndResolveContradictions, runWeightTuningLoop, } from './contradiction-sync.js';
63
82
  import { searchSubgraph, searchSubgraphBroadened, getSubgraphFactCount, fetchFactById } from './subgraph-search.js';
64
- import { executePinOperation, validatePinArgs, } from './pin.js';
65
- import { executeRetype, executeSetScope, validateRetypeArgs, validateSetScopeArgs, } from './retype-setscope.js';
66
83
  import { PluginHotCache } from './hot-cache-wrapper.js';
67
84
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
68
85
  import { buildRelayHeaders } from './relay-headers.js';
69
86
  import { readBillingCache, writeBillingCache, BILLING_CACHE_PATH, } from './billing-cache.js';
70
- import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, patchOpenClawConfig, writePluginManifest, writePluginError, readPluginLoadedManifest, } from './fs-helpers.js';
87
+ import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, patchOpenClawConfig, checkCredentialsFileMode, } from './fs-helpers.js';
71
88
  import { isRcBuild } from './qa-bug-report.js';
72
89
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
73
90
  import { resolveRestartAuth, rejectMessageFor, } from './restart-auth.js';
@@ -75,12 +92,15 @@ import { recordInboundUser, getDistinctInboundUserCount, resolveTrackerPath, } f
75
92
  import { detectFirstRun, buildWelcomePrepend } from './first-run.js';
76
93
  import { buildPairRoutes } from './pair-http.js';
77
94
  import { detectGatewayHost } from './gateway-url.js';
95
+ import { registerNativeMemory } from './native-memory.js';
96
+ import { ensureSkillRegistered } from './skill-register.js';
78
97
  import { validateMnemonic } from '@scure/bip39';
79
98
  import { wordlist } from '@scure/bip39/wordlists/english.js';
80
99
  import crypto from 'node:crypto';
81
100
  import { createRequire } from 'node:module';
82
101
  import { fileURLToPath } from 'node:url';
83
102
  import * as nodePath from 'node:path';
103
+ import { writeImportState, readImportState, isImportStale, readMostRecentActiveImport, } from './import-state-manager.js';
84
104
  // CJS-style require for the @totalreclaw/core WASM module. We keep this
85
105
  // load path lazy (only inside getSmartImportWasm() below) so a partial
86
106
  // install of the dependency tree doesn't crash module init. Bare
@@ -210,15 +230,17 @@ function buildPairingUrl(api, session) {
210
230
  // issue #110 fix 4: inside Docker the LAN IP is container-internal
211
231
  // and useless. Loopback localhost only works for `docker exec`
212
232
  // tests. The CORRECT pair URL for Docker is the relay-brokered
213
- // path served by the `totalreclaw_pair` agent tool (CONFIG.pairMode
214
- // === 'relay' since rc.11). The CLI-only path here cannot mint a
215
- // relay session synchronously (the relay handshake needs a WS
216
- // round-trip), so we emit the loopback URL with a LOUD warning
217
- // 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.
218
239
  api.logger.warn(`TotalReclaw: Docker container detected — pairing URL falling back to ` +
219
240
  `http://localhost:${port}, which is unreachable from the host browser. ` +
220
- `Use the totalreclaw_pair AGENT TOOL (relay-brokered, universally reachable) ` +
221
- `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 ` +
222
244
  `to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
223
245
  `Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
224
246
  `air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`);
@@ -401,6 +423,11 @@ function getMaxFactsPerExtraction() {
401
423
  const cache = readBillingCache();
402
424
  if (cache?.features?.max_facts_per_extraction != null)
403
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
+ }
404
431
  return MAX_FACTS_PER_EXTRACTION;
405
432
  }
406
433
  /**
@@ -417,9 +444,10 @@ const MEMORY_HEADER = `# Memory
417
444
 
418
445
  > **TotalReclaw is active.** Your encrypted memories are loaded automatically
419
446
  > at the start of each conversation — no need to search this file for them.
420
- > Use \`totalreclaw_remember\` to store new memories and \`totalreclaw_recall\`
421
- > to search. Do NOT write user facts, preferences, or decisions to this file.
422
- > 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).
423
451
 
424
452
  `;
425
453
  function ensureMemoryHeader(logger) {
@@ -569,7 +597,12 @@ async function initialize(logger) {
569
597
  }
570
598
  if (!masterPassword) {
571
599
  needsSetup = true;
572
- logger.info('TotalReclaw: no recovery phrase available run `openclaw totalreclaw onboard` in a terminal to set up');
600
+ // No credentials yetpairing 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.');
573
606
  return;
574
607
  }
575
608
  apiClient = createApiClient(serverUrl);
@@ -701,12 +734,15 @@ async function initialize(logger) {
701
734
  const billingData = await resp.json();
702
735
  const tier = billingData.tier;
703
736
  const expiresAt = billingData.expires_at;
704
- // Populate billing cache for future use.
737
+ // Populate billing cache for future use. Copy the relay's
738
+ // authoritative chain_id so it lands on disk and drives the runtime
739
+ // chain override verbatim (#402).
705
740
  writeBillingCache({
706
741
  tier: tier || 'free',
707
742
  free_writes_used: billingData.free_writes_used ?? 0,
708
743
  free_writes_limit: billingData.free_writes_limit ?? 0,
709
744
  features: billingData.features,
745
+ chain_id: billingData.chain_id,
710
746
  checked_at: Date.now(),
711
747
  });
712
748
  if (tier === 'pro' && expiresAt) {
@@ -721,15 +757,35 @@ async function initialize(logger) {
721
757
  // Best-effort — don't block initialization on billing check failure.
722
758
  }
723
759
  }
760
+ // 1h freshness auto-resume: if a previous session left an import in running
761
+ // state and last_updated is < 1h ago, re-spawn the background task.
762
+ // If > 1h, the status check will mark it stale when the user queries it.
763
+ const activeImport = readMostRecentActiveImport();
764
+ if (activeImport && activeImport.status === 'running' && !isImportStale(activeImport)) {
765
+ logger.info(`Import ${activeImport.import_id}: resuming background task from chunk ${activeImport.batch_done * 25}/${activeImport.total_chunks}`);
766
+ // The import file path is needed for re-parse. If not present we can't resume automatically.
767
+ if (activeImport.file_path) {
768
+ void handlePluginImportFrom({ source: activeImport.source, file_path: activeImport.file_path, resume_id: activeImport.import_id }, logger).catch((err) => {
769
+ const msg = err instanceof Error ? err.message : String(err);
770
+ logger.warn(`Import ${activeImport.import_id}: auto-resume failed: ${msg}`);
771
+ });
772
+ }
773
+ }
724
774
  }
725
775
  function isDocker() {
726
776
  return isRunningInDocker();
727
777
  }
728
778
  function buildSetupErrorMsg() {
729
- return 'TotalReclaw setup required. Use the `totalreclaw_setup` tool with a 12-word BIP-39 recovery phrase.\n\n' +
730
- '1. Ask the user if they have an existing recovery phrase, or generate a new one with `npx @totalreclaw/mcp-server setup`.\n' +
731
- '2. Call `totalreclaw_setup` with the phrase no gateway restart needed.\n' +
732
- ' (Optional: set TOTALRECLAW_SELF_HOSTED=true if using your own server instead of the managed service.)';
779
+ // NOTE: the legacy `totalreclaw_setup` agent tool was retired in 3.2.0
780
+ // (phrase-safety: the agent must never accept or relay a recovery phrase).
781
+ // The ONLY setup surface is the QR-pair flow: the agent cannot mint a
782
+ // pair URL itself, so it must direct the user to the CLI. This message is
783
+ // thrown by `requireFullSetup` (currently only reached via dead 3.2 tool
784
+ // handlers; kept accurate so any future caller gets the correct pointer).
785
+ return 'TotalReclaw setup required. Pairing is QR-only — the recovery phrase is generated and encrypted in-browser and never enters this chat.\n\n' +
786
+ 'Run `tr pair --url-pin` on the gateway host (or `openclaw totalreclaw pair generate --url-pin-only`) ' +
787
+ 'and hand the user the returned `url` and `pin`. The user opens the URL in a browser to complete pairing. ' +
788
+ 'Do NOT ask the user for a recovery phrase and do NOT attempt to generate or relay one yourself.';
733
789
  }
734
790
  function buildSetupErrorMsgLegacy() {
735
791
  const base = 'TotalReclaw setup required:\n' +
@@ -758,8 +814,9 @@ const SETUP_ERROR_MSG = buildSetupErrorMsg();
758
814
  * Ensure `initialize()` has completed (runs at most once).
759
815
  *
760
816
  * If `needsSetup` is true after init, attempts a hot-reload from
761
- * credentials.json in case the mnemonic was written there by a
762
- * `totalreclaw_setup` tool call or `npx @totalreclaw/mcp-server setup`.
817
+ * credentials.json in case the mnemonic was just written there by the
818
+ * pair-completion HTTP route (`/plugin/totalreclaw/pair/respond`
819
+ * `completePairing`) or the `tr pair` CLI on another process.
763
820
  */
764
821
  async function ensureInitialized(logger) {
765
822
  if (!initPromise) {
@@ -767,7 +824,7 @@ async function ensureInitialized(logger) {
767
824
  }
768
825
  await initPromise;
769
826
  // Hot-reload: if setup is still needed, check if credentials.json
770
- // now has a mnemonic (written by totalreclaw_setup or MCP setup CLI).
827
+ // now has a mnemonic (written by the pair HTTP route / `tr pair` CLI).
771
828
  if (needsSetup) {
772
829
  await attemptHotReload(logger);
773
830
  }
@@ -776,9 +833,9 @@ async function ensureInitialized(logger) {
776
833
  * Attempt to hot-reload credentials from credentials.json.
777
834
  *
778
835
  * Called when `needsSetup` is true — checks if credentials.json contains
779
- * a mnemonic (written by the `totalreclaw_setup` tool or MCP setup CLI).
780
- * If found, re-derives keys and completes initialization without requiring
781
- * a gateway restart.
836
+ * a mnemonic (written by the pair-completion HTTP route or `tr pair` CLI
837
+ * on another process). If found, re-derives keys and completes
838
+ * initialization without requiring a gateway restart.
782
839
  */
783
840
  async function attemptHotReload(logger) {
784
841
  try {
@@ -802,9 +859,18 @@ async function attemptHotReload(logger) {
802
859
  /**
803
860
  * Force re-initialization with a specific mnemonic.
804
861
  *
805
- * Called by the `totalreclaw_setup` tool. Clears stale credentials from
806
- * disk so that `initialize()` treats this as a fresh registration and
807
- * persists the NEW mnemonic + freshly derived salt/userId.
862
+ * LEGACY (Phase 3.2): the only caller was the `totalreclaw_setup` agent
863
+ * tool, which was retired because accepting a recovery phrase via an agent
864
+ * tool violated phrase-safety (the phrase must never enter an LLM context).
865
+ * The function currently has NO callers; it is retained because the
866
+ * credential-rotation invariant documented below still describes a real
867
+ * trap for any future credential-rotating surface, and removing the body
868
+ * would lose that institutional knowledge. Safe to delete once confirmed
869
+ * unused across the whole plugin tree.
870
+ *
871
+ * Clears stale credentials from disk so that `initialize()` treats this as
872
+ * a fresh registration and persists the NEW mnemonic + freshly derived
873
+ * salt/userId.
808
874
  *
809
875
  * Without clearing credentials.json first, `initialize()` would load the
810
876
  * OLD salt and userId, derive keys from (new mnemonic + old salt), skip
@@ -1375,7 +1441,7 @@ function relativeTime(isoOrMs) {
1375
1441
  * Configurable via TOTALRECLAW_MIN_IMPORTANCE env var (default: 3).
1376
1442
  *
1377
1443
  * NOTE: This filter is ONLY applied to auto-extraction (hooks).
1378
- * The explicit `totalreclaw_remember` tool always stores regardless of importance.
1444
+ * The explicit `tr remember` CLI always stores regardless of importance.
1379
1445
  */
1380
1446
  const MIN_IMPORTANCE_THRESHOLD = CONFIG.minImportance;
1381
1447
  /**
@@ -1743,7 +1809,7 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
1743
1809
  // Submit subgraph payloads one fact at a time (sequential single-call UserOps).
1744
1810
  // Batch executeBatch UserOps have persistent gas estimation issues on Base Sepolia
1745
1811
  // that cause on-chain reverts. Single-fact UserOps use the simpler submitFactOnChain
1746
- // path which works reliably (same path as totalreclaw_remember). Each submission
1812
+ // path which works reliably (same path the `tr remember` CLI uses). Each submission
1747
1813
  // polls for receipt (120s) before proceeding, so nonce is consumed before the next.
1748
1814
  let batchError;
1749
1815
  if (pendingPayloads.length > 0 && isSubgraphMode()) {
@@ -1795,10 +1861,11 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
1795
1861
  return stored;
1796
1862
  }
1797
1863
  // ---------------------------------------------------------------------------
1798
- // Import handler (for totalreclaw_import_from tool)
1864
+ // Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
1799
1865
  // ---------------------------------------------------------------------------
1800
1866
  /**
1801
- * Handle import_from tool calls in the plugin context.
1867
+ * Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
1868
+ * agent tool before Phase 3.2 retired the agent tools).
1802
1869
  *
1803
1870
  * Two paths:
1804
1871
  * 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
@@ -1811,10 +1878,12 @@ async function handlePluginImportFrom(params, logger) {
1811
1878
  _importInProgress = true;
1812
1879
  const startTime = Date.now();
1813
1880
  const source = params.source;
1814
- const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini', 'memoclaw', 'generic-json', 'generic-csv'];
1881
+ const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'];
1815
1882
  if (!source || !validSources.includes(source)) {
1816
1883
  return { success: false, error: `Invalid source. Must be one of: ${validSources.join(', ')}` };
1817
1884
  }
1885
+ // Generate import_id up front so dry-run responses and background tasks share it.
1886
+ const importId = params.resume_id ?? crypto.randomUUID();
1818
1887
  try {
1819
1888
  const { getAdapter } = await import('./import-adapters/index.js');
1820
1889
  const adapter = getAdapter(source);
@@ -1847,6 +1916,7 @@ async function handlePluginImportFrom(params, logger) {
1847
1916
  return {
1848
1917
  success: true,
1849
1918
  dry_run: true,
1919
+ import_id: importId,
1850
1920
  source,
1851
1921
  total_chunks: totalChunks,
1852
1922
  total_messages: parseResult.totalMessages,
@@ -1854,19 +1924,19 @@ async function handlePluginImportFrom(params, logger) {
1854
1924
  estimated_batches: estimatedBatches,
1855
1925
  estimated_minutes: estimatedMinutes,
1856
1926
  batch_size: BATCH_SIZE,
1857
- use_background: totalChunks > 50,
1858
1927
  preview: parseResult.chunks.slice(0, 5).map((c) => ({
1859
1928
  title: c.title,
1860
1929
  messages: c.messages.length,
1861
1930
  first_message: c.messages[0]?.text.slice(0, 100),
1862
1931
  })),
1863
- note: `Estimated ${estimatedFacts} facts from ${totalChunks} chunks (~${estimatedMinutes} min).${totalChunks > 50 ? ' Recommended: background import via sessions_spawn.' : ''}`,
1932
+ note: `Estimated ${estimatedFacts} facts from ${totalChunks} chunks (~${estimatedMinutes} min). Confirm to start background import.`,
1864
1933
  warnings: parseResult.warnings,
1865
1934
  };
1866
1935
  }
1867
1936
  return {
1868
1937
  success: true,
1869
1938
  dry_run: true,
1939
+ import_id: importId,
1870
1940
  source,
1871
1941
  total_found: parseResult.facts.length,
1872
1942
  preview: parseResult.facts.slice(0, 10).map((f) => ({
@@ -1877,9 +1947,56 @@ async function handlePluginImportFrom(params, logger) {
1877
1947
  warnings: parseResult.warnings,
1878
1948
  };
1879
1949
  }
1880
- // ── Path 1: Conversation chunks (ChatGPT, Claude) — LLM extraction ──
1950
+ // ── Path 1: Conversation chunks (ChatGPT, Claude, Gemini) — background execution ──
1881
1951
  if (hasChunks) {
1882
- return handleChunkImport(parseResult.chunks, parseResult.totalMessages, source, logger, startTime, parseResult.warnings);
1952
+ const totalChunks = parseResult.chunks.length;
1953
+ const BATCH_SIZE = 25;
1954
+ const SECONDS_PER_BATCH = 45;
1955
+ const estimatedBatches = Math.ceil(totalChunks / BATCH_SIZE);
1956
+ const estimatedMinutes = Math.ceil(estimatedBatches * SECONDS_PER_BATCH / 60);
1957
+ const estimatedTotalFacts = Math.round(totalChunks * 2.5);
1958
+ const now = new Date();
1959
+ const initialState = {
1960
+ import_id: importId,
1961
+ source,
1962
+ status: 'running',
1963
+ started_at: now.toISOString(),
1964
+ last_updated: now.toISOString(),
1965
+ total_chunks: totalChunks,
1966
+ total_messages: parseResult.totalMessages,
1967
+ batch_done: 0,
1968
+ batch_total: estimatedBatches,
1969
+ facts_stored: 0,
1970
+ facts_extracted: 0,
1971
+ dups_skipped: 0,
1972
+ errors: [],
1973
+ file_path: params.file_path,
1974
+ estimated_total_facts: estimatedTotalFacts,
1975
+ estimated_minutes: estimatedMinutes,
1976
+ estimated_completion_iso: new Date(now.getTime() + estimatedBatches * SECONDS_PER_BATCH * 1000).toISOString(),
1977
+ disclosure_confirmed: !!(params.disclosure_confirmed),
1978
+ };
1979
+ writeImportState(initialState);
1980
+ logger.info(`Import ${importId}: background task started (${totalChunks} chunks, ~${estimatedMinutes}min)`);
1981
+ void handleChunkImport(parseResult.chunks, parseResult.totalMessages, source, logger, startTime, parseResult.warnings, importId).catch((err) => {
1982
+ const msg = err instanceof Error ? err.message : String(err);
1983
+ logger.warn(`Import ${importId}: background task failed: ${msg}`);
1984
+ const failedState = readImportState(importId);
1985
+ if (failedState && failedState.status === 'running') {
1986
+ writeImportState({ ...failedState, status: 'failed', errors: [...failedState.errors, msg] });
1987
+ }
1988
+ });
1989
+ return {
1990
+ import_id: importId,
1991
+ status: 'running',
1992
+ source,
1993
+ total_chunks: totalChunks,
1994
+ estimated_batches: estimatedBatches,
1995
+ estimated_minutes: estimatedMinutes,
1996
+ estimated_completion_iso: initialState.estimated_completion_iso,
1997
+ 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).`,
1998
+ warnings: parseResult.warnings,
1999
+ };
1883
2000
  }
1884
2001
  // ── Path 2: Pre-structured facts (Mem0, MCP Memory) — direct store ──
1885
2002
  const extractedFacts = parseResult.facts.map((f) => ({
@@ -1913,7 +2030,7 @@ async function handlePluginImportFrom(params, logger) {
1913
2030
  return {
1914
2031
  success: totalStored > 0,
1915
2032
  source,
1916
- import_id: crypto.randomUUID(),
2033
+ import_id: importId,
1917
2034
  total_found: parseResult.facts.length,
1918
2035
  imported: totalStored,
1919
2036
  skipped: parseResult.facts.length - totalStored,
@@ -2112,7 +2229,7 @@ async function handleBatchImport(params, logger) {
2112
2229
  const content = params.content;
2113
2230
  const offset = params.offset ?? 0;
2114
2231
  const batchSize = params.batch_size ?? 25;
2115
- const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini', 'memoclaw', 'generic-json', 'generic-csv'];
2232
+ const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'];
2116
2233
  if (!source || !validSources.includes(source)) {
2117
2234
  return { success: false, error: `Invalid source. Must be one of: ${validSources.join(', ')}` };
2118
2235
  }
@@ -2201,15 +2318,25 @@ async function handleBatchImport(params, logger) {
2201
2318
  * for auto-extraction during live conversations. This ensures import quality
2202
2319
  * matches conversation extraction quality.
2203
2320
  */
2204
- async function handleChunkImport(chunks, totalMessages, source, logger, startTime, warnings) {
2321
+ async function handleChunkImport(chunks, totalMessages, source, logger, startTime, warnings, importId) {
2205
2322
  let totalExtracted = 0;
2206
2323
  let totalStored = 0;
2207
2324
  let chunksProcessed = 0;
2208
2325
  let chunksSkipped = 0;
2326
+ const resolvedImportId = importId ?? crypto.randomUUID();
2209
2327
  let storeError;
2210
2328
  // --- Smart Import: Profile + Triage ---
2211
2329
  const smartCtx = await runSmartImportPipeline(chunks, logger);
2330
+ const CHECKPOINT_EVERY = 25; // write state file every N chunks
2212
2331
  for (let i = 0; i < chunks.length; i++) {
2332
+ // Check abort flag from state file before each chunk (background task may be cancelled).
2333
+ if (importId) {
2334
+ const currentState = readImportState(importId);
2335
+ if (currentState?.status === 'aborted') {
2336
+ logger.info(`Import ${importId}: abort flag detected at chunk ${i + 1}/${chunks.length}, stopping`);
2337
+ break;
2338
+ }
2339
+ }
2213
2340
  const chunk = chunks[i];
2214
2341
  chunksProcessed++;
2215
2342
  // Smart import: skip chunks triaged as SKIP
@@ -2248,6 +2375,25 @@ async function handleChunkImport(chunks, totalMessages, source, logger, startTim
2248
2375
  break; // Stop processing further chunks — a zombie UserOp may block writes
2249
2376
  }
2250
2377
  }
2378
+ // Checkpoint state file periodically so _import_status reflects live progress.
2379
+ if (importId && chunksProcessed % CHECKPOINT_EVERY === 0) {
2380
+ const liveState = readImportState(importId);
2381
+ if (liveState) {
2382
+ const estimatedBatches = liveState.batch_total || 1;
2383
+ const doneBatches = Math.floor(chunksProcessed / CHECKPOINT_EVERY);
2384
+ const elapsed = Date.now() - new Date(liveState.started_at).getTime();
2385
+ const secPerBatch = doneBatches > 0 ? elapsed / 1000 / doneBatches : 45;
2386
+ const remaining = estimatedBatches - doneBatches;
2387
+ const etaMs = remaining * secPerBatch * 1000;
2388
+ writeImportState({
2389
+ ...liveState,
2390
+ batch_done: doneBatches,
2391
+ facts_stored: totalStored,
2392
+ facts_extracted: totalExtracted,
2393
+ estimated_completion_iso: new Date(Date.now() + etaMs).toISOString(),
2394
+ });
2395
+ }
2396
+ }
2251
2397
  }
2252
2398
  if (totalExtracted === 0 && chunks.length > 0 && !storeError && chunksSkipped < chunks.length) {
2253
2399
  warnings.push(`Processed ${chunks.length} conversation chunks (${totalMessages} messages) but the LLM ` +
@@ -2257,10 +2403,26 @@ async function handleChunkImport(chunks, totalMessages, source, logger, startTim
2257
2403
  if (storeError) {
2258
2404
  warnings.push(`Import stopped early: ${storeError}. ${chunks.length - chunksProcessed} chunk(s) not processed.`);
2259
2405
  }
2406
+ // Final state file write for background imports.
2407
+ if (importId) {
2408
+ const finalState = readImportState(importId);
2409
+ if (finalState) {
2410
+ const finalStatus = storeError ? 'failed' : (finalState.status === 'aborted' ? 'aborted' : 'completed');
2411
+ writeImportState({
2412
+ ...finalState,
2413
+ status: finalStatus,
2414
+ batch_done: finalState.batch_total,
2415
+ facts_stored: totalStored,
2416
+ facts_extracted: totalExtracted,
2417
+ errors: storeError ? [...finalState.errors, storeError] : finalState.errors,
2418
+ });
2419
+ }
2420
+ _importInProgress = false;
2421
+ }
2260
2422
  return {
2261
2423
  success: totalStored > 0 || totalExtracted > 0,
2262
2424
  source,
2263
- import_id: crypto.randomUUID(),
2425
+ import_id: resolvedImportId,
2264
2426
  total_chunks: chunks.length,
2265
2427
  chunks_processed: chunksProcessed,
2266
2428
  chunks_skipped: chunksSkipped,
@@ -2279,6 +2441,395 @@ async function handleChunkImport(chunks, totalMessages, source, logger, startTim
2279
2441
  };
2280
2442
  }
2281
2443
  // ---------------------------------------------------------------------------
2444
+ // Import status + abort handlers
2445
+ // ---------------------------------------------------------------------------
2446
+ async function handleImportStatus(params, logger) {
2447
+ const importId = params.import_id;
2448
+ let state;
2449
+ if (importId) {
2450
+ state = readImportState(importId);
2451
+ if (!state)
2452
+ return { error: `No import found with id: ${importId}` };
2453
+ }
2454
+ else {
2455
+ state = readMostRecentActiveImport();
2456
+ if (!state)
2457
+ 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.)' };
2458
+ }
2459
+ // 1h freshness guard: mark stale imports as failed and prompt user to resume.
2460
+ if (state.status === 'running' && isImportStale(state)) {
2461
+ writeImportState({ ...state, status: 'failed', errors: [...state.errors, 'Stale: no progress in 1h'] });
2462
+ logger.info(`Import ${state.import_id}: marked stale (no progress in 1h)`);
2463
+ return {
2464
+ import_id: state.import_id,
2465
+ status: 'failed',
2466
+ stale: true,
2467
+ facts_stored: state.facts_stored,
2468
+ 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.',
2469
+ resume_id: state.import_id,
2470
+ };
2471
+ }
2472
+ const now = Date.now();
2473
+ const elapsedMs = now - new Date(state.started_at).getTime();
2474
+ const secPerBatch = state.batch_done > 0 ? elapsedMs / 1000 / state.batch_done : 45;
2475
+ const remaining = Math.max(0, state.batch_total - state.batch_done);
2476
+ const etaSeconds = state.status === 'running' ? Math.round(remaining * secPerBatch) : 0;
2477
+ return {
2478
+ import_id: state.import_id,
2479
+ status: state.status,
2480
+ batch_done: state.batch_done,
2481
+ batch_total: state.batch_total,
2482
+ facts_stored: state.facts_stored,
2483
+ dups_skipped: state.dups_skipped,
2484
+ eta_seconds: etaSeconds,
2485
+ completion_iso: state.status === 'running'
2486
+ ? new Date(now + etaSeconds * 1000).toISOString()
2487
+ : state.last_updated,
2488
+ source: state.source,
2489
+ started_at: state.started_at,
2490
+ errors: state.errors,
2491
+ };
2492
+ }
2493
+ async function handleImportAbort(params, logger) {
2494
+ const importId = params.import_id;
2495
+ if (!importId)
2496
+ return { error: 'import_id is required' };
2497
+ const state = readImportState(importId);
2498
+ if (!state)
2499
+ return { error: `No import found with id: ${importId}` };
2500
+ if (state.status === 'aborted') {
2501
+ return { aborted: true, idempotent: true, import_id: importId, facts_already_stored: state.facts_stored };
2502
+ }
2503
+ if (state.status === 'completed') {
2504
+ return { error: 'Import already completed — nothing to abort', import_id: importId, facts_stored: state.facts_stored };
2505
+ }
2506
+ writeImportState({ ...state, status: 'aborted' });
2507
+ logger.info(`Import ${importId}: abort requested (${state.facts_stored} facts already stored)`);
2508
+ return {
2509
+ aborted: true,
2510
+ import_id: importId,
2511
+ facts_already_stored: state.facts_stored,
2512
+ message: 'Import abort requested. The background task will stop at the next chunk boundary. Already-stored facts are kept.',
2513
+ };
2514
+ }
2515
+ // ---------------------------------------------------------------------------
2516
+ // buildRecallDeps — bind the real recall pipeline into the closures the
2517
+ // native MemoryPluginCapability wiring helper (Task 2.7) consumes.
2518
+ //
2519
+ // WHY THIS LIVES IN index.ts (not in native-memory.ts):
2520
+ // The real `recall` / `getById` closures must reach unexported index.ts
2521
+ // helpers (generateBlindIndices, generateEmbedding, getLSHHasher,
2522
+ // computeCandidatePool, isDigestBlob, readClaimFromBlob, searchSubgraph,
2523
+ // searchSubgraphBroadened, getSubgraphFactCount, fetchFactById,
2524
+ // ensureInitialized) AND module-level state (authKeyHex, encryptionKey,
2525
+ // userId, subgraphOwner). Lifting these out of index.ts is a high
2526
+ // blast-radius refactor with scanner-trap risk — out of scope for 2.7.
2527
+ //
2528
+ // native-memory.ts (the wiring helper) is pure orchestration and stays
2529
+ // scanner-trivial; the closures stay here where the rest of the plugin's
2530
+ // network surface lives.
2531
+ //
2532
+ // LAZY CONTEXT RESOLUTION:
2533
+ // The paired-account context (authKeyHex / encryptionKey / userId /
2534
+ // subgraphOwner) is NOT resolved synchronously at register() time. It is
2535
+ // populated by `initialize()` on the first tool/hook call via
2536
+ // `ensureInitialized()`. So each closure calls `ensureInitialized(logger)`
2537
+ // internally before touching the module-level state — the same lazy-init
2538
+ // seam the retired totalreclaw_recall tool used to use (then via
2539
+ // `requireFullSetup`).
2540
+ //
2541
+ // If setup is incomplete (no credentials), `ensureInitialized` returns
2542
+ // with `needsSetup=true`; the closures then surface a typed error
2543
+ // (`getMemorySearchManager` will return `{ manager: null, error }` from
2544
+ // the runtime wrapper, which the tools convert into the disabled-result
2545
+ // payload the agent recognizes).
2546
+ //
2547
+ // SCANNER NOTE:
2548
+ // This file (index.ts) is NOT scanner-clean — it is the plugin's network
2549
+ // surface and contains the env+net pair legitimately (centralized via
2550
+ // config.ts reads + relay.ts). The closures here CALL the scanner-clean
2551
+ // subgraph-search / vault-crypto / reranker modules but do not add any
2552
+ // NEW env-harvesting or exfiltration pattern: they read only the
2553
+ // already-resolved module-level state. `check-scanner` was already
2554
+ // non-zero on index.ts before this task (the file legitimately pairs
2555
+ // config reads with network calls); the closures do not change that
2556
+ // posture. The NEW files (native-memory.ts, register-native.test.ts)
2557
+ // are scanner-clean by construction (verified).
2558
+ // ---------------------------------------------------------------------------
2559
+ /**
2560
+ * Build the deps for the native MemoryPluginCapability. Returns the
2561
+ * `recall` / `getById` closures bound to the real subgraph + decrypt +
2562
+ * reranker pipeline, plus optional `quota` / `pinned` prompt-builder inputs
2563
+ * (currently defaulted — see TODO(task 2.7b / H1 gate) below).
2564
+ *
2565
+ * `logger` is threaded in so the closures can call `ensureInitialized` (the
2566
+ * lazy-init seam used by every other tool handler in this file).
2567
+ *
2568
+ * @param logger the OpenClaw plugin logger (forwarded into ensureInitialized)
2569
+ */
2570
+ function buildRecallDeps(logger) {
2571
+ // -------------------------------------------------------------------
2572
+ // recall(): the load-bearing closure. This is the search/decrypt/rerank
2573
+ // pipeline that backs the native memory_search tool via the
2574
+ // TrMemorySearchManager adapter. It replaced the retired totalreclaw_recall
2575
+ // agent tool handler (Phase 3.2) MINUS the tool-level result formatting +
2576
+ // hot-cache bookkeeping (those are tool concerns; the native memory
2577
+ // pipeline only needs the TrFact[]). Returns TrFact[] shaped for the
2578
+ // TrMemorySearchManager adapter (memory-runtime.ts).
2579
+ // -------------------------------------------------------------------
2580
+ const recall = async (query, opts) => {
2581
+ // Lazy-init: this is the first seam the closure hits. If the user
2582
+ // is not paired, ensureInitialized returns with needsSetup=true; we
2583
+ // surface that as an empty result (the before_tool_call gate in
2584
+ // tool-gating.ts normally intercepts memory_search BEFORE this runs
2585
+ // when state != active, but fail-soft here too — a search with no
2586
+ // credentials returning [] is benign; the agent treats empty
2587
+ // results as "no memories found").
2588
+ await ensureInitialized(logger);
2589
+ // Guard: if setup is incomplete OR we're missing the pipeline state,
2590
+ // return []. This is fail-soft: the user sees "no memories" rather
2591
+ // than a thrown error out of the memory_search tool boundary.
2592
+ if (needsSetup || !encryptionKey || !authKeyHex)
2593
+ return [];
2594
+ // subgraphOwner may be null on SA-derivation failure (see initialize()).
2595
+ // The subgraph path requires a non-null owner (Bytes!); if missing,
2596
+ // we cannot run recall — return [].
2597
+ if (isSubgraphMode() && !subgraphOwner)
2598
+ return [];
2599
+ const k = Math.min(opts?.maxResults ?? 8, 20);
2600
+ // 1. Generate word trapdoors (blind indices for the query).
2601
+ const wordTrapdoors = generateBlindIndices(query);
2602
+ // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
2603
+ let queryEmbedding = null;
2604
+ let lshTrapdoors = [];
2605
+ try {
2606
+ queryEmbedding = await generateEmbedding(query, { isQuery: true });
2607
+ const hasher = getLSHHasher(logger);
2608
+ if (hasher && queryEmbedding) {
2609
+ lshTrapdoors = hasher.hash(queryEmbedding);
2610
+ }
2611
+ }
2612
+ catch (err) {
2613
+ const msg = err instanceof Error ? err.message : String(err);
2614
+ logger.warn(`native recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
2615
+ }
2616
+ // 3. Merge word + LSH trapdoors.
2617
+ const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
2618
+ if (allTrapdoors.length === 0)
2619
+ return [];
2620
+ // 4. Build reranker candidates from the decrypted subgraph results.
2621
+ const rerankerCandidates = [];
2622
+ if (isSubgraphMode()) {
2623
+ // --- Subgraph search path (the canonical path for managed installs) ---
2624
+ const factCount = await getSubgraphFactCount(subgraphOwner || userId, authKeyHex);
2625
+ const pool = computeCandidatePool(factCount);
2626
+ let subgraphResults = await searchSubgraph(subgraphOwner || userId, allTrapdoors, pool, authKeyHex);
2627
+ // Broadened search + merge — vocabulary-mismatch safety net (mirrors
2628
+ // the recall tool: ensures "preferences" still matches "prefer").
2629
+ try {
2630
+ const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId, pool, authKeyHex);
2631
+ const existingIds = new Set(subgraphResults.map((r) => r.id));
2632
+ for (const br of broadenedResults) {
2633
+ if (!existingIds.has(br.id))
2634
+ subgraphResults.push(br);
2635
+ }
2636
+ }
2637
+ catch {
2638
+ // best-effort
2639
+ }
2640
+ for (const result of subgraphResults) {
2641
+ try {
2642
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
2643
+ if (isDigestBlob(docJson))
2644
+ continue;
2645
+ const doc = readClaimFromBlob(docJson);
2646
+ let decryptedEmbedding;
2647
+ if (result.encryptedEmbedding) {
2648
+ try {
2649
+ decryptedEmbedding = JSON.parse(decryptFromHex(result.encryptedEmbedding, encryptionKey));
2650
+ }
2651
+ catch {
2652
+ // embedding decryption failed -- proceed without it
2653
+ }
2654
+ }
2655
+ // Dim-mismatch fallback: regenerate the embedding from text so
2656
+ // the reranker's cosine component stays meaningful across model
2657
+ // upgrades. Mirrors the recall tool exactly.
2658
+ if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
2659
+ try {
2660
+ decryptedEmbedding = await generateEmbedding(doc.text);
2661
+ }
2662
+ catch {
2663
+ decryptedEmbedding = undefined;
2664
+ }
2665
+ }
2666
+ rerankerCandidates.push({
2667
+ id: result.id,
2668
+ text: doc.text,
2669
+ embedding: decryptedEmbedding,
2670
+ importance: doc.importance / 10,
2671
+ createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
2672
+ // Retrieval v2 Tier 1 source — surfaced so applySourceWeights
2673
+ // could multiply the final RRF score (left false here to match
2674
+ // the recall tool's current behavior; TODO(task 2.7b): wire
2675
+ // source weighting for the native path at the H1 QA gate).
2676
+ source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
2677
+ });
2678
+ }
2679
+ catch {
2680
+ // Skip candidates we cannot decrypt (corrupted / wrong key).
2681
+ }
2682
+ }
2683
+ }
2684
+ else {
2685
+ // --- Server search path (legacy / self-hosted) ---
2686
+ // The non-subgraph path uses apiClient.search. The native memory
2687
+ // pipeline is intended for managed (subgraph) installs, but we keep
2688
+ // parity with the recall tool so self-hosted users get recall too.
2689
+ if (!apiClient || !userId)
2690
+ return [];
2691
+ const factCount = await getFactCount(logger);
2692
+ const pool = computeCandidatePool(factCount);
2693
+ const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
2694
+ for (const candidate of candidates) {
2695
+ try {
2696
+ const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
2697
+ if (isDigestBlob(docJson))
2698
+ continue;
2699
+ const doc = readClaimFromBlob(docJson);
2700
+ let decryptedEmbedding;
2701
+ if (candidate.encrypted_embedding) {
2702
+ try {
2703
+ decryptedEmbedding = JSON.parse(decryptFromHex(candidate.encrypted_embedding, encryptionKey));
2704
+ }
2705
+ catch {
2706
+ // embedding decryption failed
2707
+ }
2708
+ }
2709
+ if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
2710
+ try {
2711
+ decryptedEmbedding = await generateEmbedding(doc.text);
2712
+ }
2713
+ catch {
2714
+ decryptedEmbedding = undefined;
2715
+ }
2716
+ }
2717
+ rerankerCandidates.push({
2718
+ id: candidate.fact_id,
2719
+ text: doc.text,
2720
+ embedding: decryptedEmbedding,
2721
+ importance: doc.importance / 10,
2722
+ createdAt: typeof candidate.timestamp === 'number'
2723
+ ? candidate.timestamp / 1000
2724
+ : new Date(candidate.timestamp).getTime() / 1000,
2725
+ source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
2726
+ });
2727
+ }
2728
+ catch {
2729
+ // Skip candidates we cannot decrypt.
2730
+ }
2731
+ }
2732
+ }
2733
+ // 5. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
2734
+ const queryIntent = detectQueryIntent(query);
2735
+ const reranked = rerank(query, queryEmbedding ?? [], rerankerCandidates, k, INTENT_WEIGHTS[queryIntent],
2736
+ // applySourceWeights=false — matches the recall tool's current
2737
+ // behavior. TODO(task 2.7b / H1 gate): flip to true for the native
2738
+ // path so Retrieval v2 Tier 1 source weighting takes effect.
2739
+ false);
2740
+ // 6. Map RerankerResult -> TrFact. The score field is rrfScore (the
2741
+ // final fused + weighted score the manager's defensive sort uses).
2742
+ return reranked.map((m) => ({
2743
+ id: m.id,
2744
+ plaintext: m.text,
2745
+ score: m.rrfScore,
2746
+ // pinned is intentionally not surfaced here today — pinned status
2747
+ // lives in claim metadata and there's no clean read-side aggregate
2748
+ // to lift in this task. See getById + pinned TODO below.
2749
+ }));
2750
+ };
2751
+ // -------------------------------------------------------------------
2752
+ // getById(): the load-bearing reverse-path closure. Mirrors the
2753
+ // pin/unpin tool's fetchFactById -> decrypt pattern (the read-back
2754
+ // reverse-path for memory_get). Returns { id, plaintext } or null.
2755
+ // -------------------------------------------------------------------
2756
+ const getById = async (id) => {
2757
+ await ensureInitialized(logger);
2758
+ // Fail-soft on missing setup / encryption key.
2759
+ if (needsSetup || !encryptionKey || !authKeyHex)
2760
+ return null;
2761
+ // The subgraph path is the canonical one; fetchFactById resolves the
2762
+ // fact by UUID and guards against owner mismatch (defense-in-depth
2763
+ // against stale IDs from another user's recall results — see
2764
+ // subgraph-search.ts fetchFactById docstring).
2765
+ if (isSubgraphMode()) {
2766
+ if (!subgraphOwner)
2767
+ return null;
2768
+ try {
2769
+ const result = await fetchFactById(subgraphOwner, id, authKeyHex);
2770
+ if (!result)
2771
+ return null;
2772
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
2773
+ if (isDigestBlob(docJson))
2774
+ return null;
2775
+ const doc = readClaimFromBlob(docJson);
2776
+ return { id, plaintext: doc.text };
2777
+ }
2778
+ catch {
2779
+ return null;
2780
+ }
2781
+ }
2782
+ // Server-path: apiClient doesn't expose a clean get-by-id; fall back
2783
+ // to a recall-style lookup using the id as a single trapdoor. This is
2784
+ // a degenerate path for self-hosted installs and rarely hit (the
2785
+ // native memory pipeline targets managed subgraph installs). Document
2786
+ // rather than gold-plate.
2787
+ // TODO(task 2.7b / H1 gate): wire apiClient get-by-id if/when exposed.
2788
+ if (!apiClient || !userId)
2789
+ return null;
2790
+ try {
2791
+ const candidates = await apiClient.search(userId, [id], 10, authKeyHex);
2792
+ const hit = candidates.find((c) => c.fact_id === id);
2793
+ if (!hit)
2794
+ return null;
2795
+ const docJson = decryptFromHex(hit.encrypted_blob, encryptionKey);
2796
+ if (isDigestBlob(docJson))
2797
+ return null;
2798
+ const doc = readClaimFromBlob(docJson);
2799
+ return { id, plaintext: doc.text };
2800
+ }
2801
+ catch {
2802
+ return null;
2803
+ }
2804
+ };
2805
+ // -------------------------------------------------------------------
2806
+ // quota + pinned: prompt-builder inputs. These drive the warning /
2807
+ // pinned-facts blocks in buildPromptSection (memory-runtime.ts).
2808
+ //
2809
+ // TODO(task 2.7b / H1 QA gate): bind these to the real paired-account
2810
+ // state. The hooks to lift are:
2811
+ // - quota: readBillingCache() in billing-cache.ts exposes
2812
+ // { free_writes_used, free_writes_limit } — when used/limit > 0.8
2813
+ // pass { usedPct }, and on a recently-observed 403 pass { denied }.
2814
+ // The billing cache is refreshed by the trajectory-poller after
2815
+ // each capture attempt; today we default to undefined so no
2816
+ // warning fires (fail-quiet — better than a false warning).
2817
+ // - pinned: there is no clean read-side `fetchPinnedFacts(owner)`
2818
+ // aggregate today. pin.ts writes pinned status into claim metadata;
2819
+ // a pinned-facts read would need either (a) a subgraph query
2820
+ // filtering on the pinned status, or (b) reuse of the hot-cache
2821
+ // pinned list. Both are extraction work — out of scope for 2.7.
2822
+ // Default to [] (no pinned block emitted).
2823
+ //
2824
+ // Returning undefined / [] here is the documented correct default. The
2825
+ // wiring helper accepts a deps object without quota/pinned, and the
2826
+ // prompt builder emits no warning / no pinned block in that case.
2827
+ // -------------------------------------------------------------------
2828
+ const quota = undefined;
2829
+ const pinned = undefined;
2830
+ return { recall, getById, quota, pinned };
2831
+ }
2832
+ // ---------------------------------------------------------------------------
2282
2833
  // Plugin definition
2283
2834
  // ---------------------------------------------------------------------------
2284
2835
  const plugin = {
@@ -2348,67 +2899,7 @@ const plugin = {
2348
2899
  },
2349
2900
  },
2350
2901
  register(api) {
2351
- // ---------------------------------------------------------------
2352
- // 3.3.2-rc.1 (issue #186) — load manifest instrumentation
2353
- // ---------------------------------------------------------------
2354
- //
2355
- // Capture every `api.registerTool({name, ...})` call so we can write
2356
- // a `.loaded.json` manifest at the end of register(). Wrap the body
2357
- // in try/catch so a register-time throw produces `.error.json` for
2358
- // agent-side filesystem verification (the CLI hangs in some Docker
2359
- // setups — issue #182 — so the manifest is the canonical "did the
2360
- // plugin load?" probe).
2361
- //
2362
- // Implementation: we intercept the api.registerTool method by
2363
- // wrapping it on the api object passed in. The wrapper inspects the
2364
- // `name` field (every TR registerTool call sets it) and forwards
2365
- // verbatim. NO behavior change to the SDK call — the original method
2366
- // is invoked with original args and `this` binding.
2367
- //
2368
- // Synchronous writes ONLY (see writePluginManifest doc): the SDK
2369
- // freezes plugin registries the moment register() returns; an async
2370
- // write would race that freeze.
2371
- const _registeredToolNames = [];
2372
- const _originalRegisterTool = api.registerTool.bind(api);
2373
- // 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
2374
- // The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
2375
- // before they reach the agent's session toolset. registerTool() calls
2376
- // succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
2377
- // But tool calls never reach execute() — the pipeline discards them before
2378
- // the agent's toolset is built.
2379
- //
2380
- // Strategy: keep all registerTool() calls intact so the plugin loader can
2381
- // verify the contracts.tools declaration and load the plugin (hooks fire).
2382
- // The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
2383
- // path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
2384
- // are dead-letter but hooks (before_agent_start, agent_end, message_received,
2385
- // before_reset) still fire via the unbroken hook code path.
2386
- //
2387
- // NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
2388
- // contracts.tools declaration against registered tools at load time and
2389
- // drops the plugin (unloads it) if no tools match. Confirmed empirically:
2390
- // no-op'ing registerTool causes the gateway to log "4 plugins" instead of
2391
- // "5 plugins" after restart (plugin excluded from active set).
2392
- //
2393
- // TODO: when OC ships a fix for issue #223, restore tool-call routing
2394
- // and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
2395
- // can stay as a convenience CLI regardless.
2396
- api.registerTool = (tool, opts) => {
2397
- try {
2398
- const t = tool;
2399
- if (t && typeof t === 'object' && typeof t.name === 'string' && t.name.length > 0) {
2400
- _registeredToolNames.push(t.name);
2401
- }
2402
- }
2403
- catch {
2404
- // Manifest is diagnostic; never let bookkeeping break tool registration.
2405
- }
2406
- _originalRegisterTool(tool, opts);
2407
- };
2408
- // Lazily resolved inside the try below — needed by both the manifest
2409
- // write and the error path. `dist/` after build, package root in tests.
2410
- let _pluginDirForManifest = null;
2411
- // NOTE: the body of register() below is intentionally NOT re-indented
2902
+ // NOTE: the body of register() below is intentionally NOT re-indent
2412
2903
  // under this `try` block — re-indenting would touch every line in a
2413
2904
  // 3,500-line function and obscure the actual hotfix diff. The closing
2414
2905
  // `} catch (registerErr: unknown) { ... }` is at the very end of
@@ -2418,9 +2909,11 @@ const plugin = {
2418
2909
  // RC-build detection (3.3.1-rc.3)
2419
2910
  // ---------------------------------------------------------------
2420
2911
  //
2421
- // `isRcBuild` reads the plugin's own version string. When true, the
2422
- // `totalreclaw_report_qa_bug` tool is registered at the end of this
2423
- // function stable builds never see it. The version is resolved via
2912
+ // `isRcBuild` reads the plugin's own version string. The resulting
2913
+ // `rcMode` flag is currently logged but has no gating effect after
2914
+ // Task 3.2 retired the RC-only `totalreclaw_report_qa_bug` agent tool
2915
+ // (the only former consumer). The flag is retained for the log line and
2916
+ // any future RC-gated diagnostic surface. The version is resolved via
2424
2917
  // `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
2425
2918
  let rcMode = false;
2426
2919
  // Plugin version resolved from package.json once at register time. Reused
@@ -2438,7 +2931,6 @@ const plugin = {
2438
2931
  // `require('node:url')` — undefined under bare-ESM Node, broke the
2439
2932
  // before_agent_start hook in the published rc.20 bundle (issue #124).
2440
2933
  const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
2441
- _pluginDirForManifest = pluginDir; // captured for #186 .loaded.json/.error.json
2442
2934
  pluginVersion = readPluginVersion(pluginDir);
2443
2935
  rcMode = isRcBuild(pluginVersion);
2444
2936
  if (rcMode) {
@@ -2492,10 +2984,10 @@ const plugin = {
2492
2984
  }
2493
2985
  // 3.3.3-rc.1 (issue #187 — ONNX decouple): kick off a non-blocking
2494
2986
  // bundle prefetch so the ~700 MB embedder tarball starts streaming
2495
- // as soon as the gateway boots, BEFORE the user completes
2496
- // `totalreclaw_pair`. Decouples the model download from the
2497
- // pair-completion gate the previous flow imposed via
2498
- // `requireFullSetup()` -> first `generateEmbedding()` call.
2987
+ // as soon as the gateway boots, BEFORE the user completes pairing
2988
+ // (`tr pair` / the `/plugin/totalreclaw/pair/*` HTTP route). Decouples
2989
+ // the model download from the pair-completion gate the previous flow
2990
+ // imposed via `requireFullSetup()` -> first `generateEmbedding()` call.
2499
2991
  // Fire-and-forget — never awaits, never throws on failure (the next
2500
2992
  // `generateEmbedding()` call retries via the same idempotent path).
2501
2993
  // Disabled when `TOTALRECLAW_DISABLE_EMBEDDER_PREFETCH=1` (CI / tests
@@ -2544,14 +3036,16 @@ const plugin = {
2544
3036
  // Best-effort. Helper logs internally and never throws.
2545
3037
  }
2546
3038
  // 3.3.9-rc.2 (issues #225 + #226): auto-patch openclaw.json for
2547
- // OpenClaw 2026.5.x. Two required config keys were not auto-applied
2548
- // by `openclaw plugins install` in 2026.5.x:
3039
+ // OpenClaw 2026.5.x. Required config keys not auto-applied by
3040
+ // `openclaw plugins install` in 2026.5.x:
2549
3041
  //
2550
- // 1. plugins.slots.memory = "totalreclaw"
2551
- // OpenClaw 2026.5.x introduced memory-slot exclusivitya
2552
- // memory-kind plugin MUST explicitly claim the slot or it is
2553
- // silently disabled (no error shown; `openclaw plugins inspect`
2554
- // shows "memory slot set to memory-core"). #225.
3042
+ // NOTE (rc.20, #402): patchOpenClawConfig now applies only the two
3043
+ // keys below. Retired: the memory slot (plugins.slots.memoryOpenClaw
3044
+ // 2026.6.8 claims it natively on install/enable), the installs
3045
+ // self-heal (plugins.installs native install owns it; a fabricated
3046
+ // record fails the host's schema validation), and the plugins.allow
3047
+ // self-append + plugins.bundledDiscovery="compat" pair (native install
3048
+ // manages the allowlist).
2555
3049
  //
2556
3050
  // 2. plugins.entries.totalreclaw.hooks.allowConversationAccess = true
2557
3051
  // Non-bundled plugins in 2026.5.x require this flag to receive
@@ -2570,19 +3064,62 @@ const plugin = {
2570
3064
  // openclaw.json at startup, not dynamically). We emit a warn so
2571
3065
  // the user and ops scripts know to trigger a restart.
2572
3066
  try {
2573
- const patchResult = patchOpenClawConfig();
3067
+ // pluginVersion is still passed for signature stability; as of rc.20
3068
+ // (#402) patchOpenClawConfig no longer consumes it (the Fix #6 installs
3069
+ // self-heal it fed was retired).
3070
+ const patchResult = patchOpenClawConfig(undefined, pluginVersion ?? undefined);
2574
3071
  if (patchResult === 'patched') {
3072
+ // 3.3.12-rc.6 (auto-QA finding 2026-05-09): previously we only
3073
+ // warned the user to manually restart. That created a silent
3074
+ // hook-failure on the FIRST gateway boot post-install — the
3075
+ // plugin loads with stale in-memory config, hook handlers
3076
+ // never register, auto-extraction never fires, and only a
3077
+ // second manual restart fixes it. First-time users hit this
3078
+ // every install. Auto-extraction QA reproduced it as 2/5
3079
+ // turns missed (hook silently no-op'd on turns 1-3).
3080
+ //
3081
+ // Fix: when the patch wrote anything, fire SIGUSR1 to our own
3082
+ // PID. The gateway accepts SIGUSR1 iff `commands.restart=true`
3083
+ // (the default); see upstream `setGatewaySigusr1RestartPolicy`.
3084
+ // The signal triggers an in-process restart that re-reads the
3085
+ // freshly-patched openclaw.json and registers hook handlers
3086
+ // with `allowConversationAccess=true` honoured.
3087
+ //
3088
+ // Idempotency: second boot reads the patched config and
3089
+ // returns `'unchanged'` from patchOpenClawConfig, so the
3090
+ // signal fires AT MOST once per config-key-change. No restart
3091
+ // loop possible.
3092
+ //
3093
+ // Defer via setImmediate so register() finishes (logger flush
3094
+ // + plugin load record writeback) before the signal lands.
3095
+ // A 250ms setTimeout adds slack for slow disk on Telegram VPS
3096
+ // (Hetzner small VPS tail-latency observed ~120ms on writes).
3097
+ //
3098
+ // Phrase-safety: process.kill on own PID is local-only; no
3099
+ // outbound markers. Already used by `/totalreclaw-restart`
3100
+ // (registered ~400 lines below) under the same scanner-safe
3101
+ // pattern.
2575
3102
  api.logger.warn('TotalReclaw: updated openclaw.json with required 2026.5.x keys ' +
2576
- '(plugins.slots.memory + hooks.allowConversationAccess + ' +
2577
- 'channels.telegram.streaming.mode + plugins.bundledDiscovery). ' +
2578
- 'Gateway restart required for the changes to take effect. ' +
2579
- 'Run `/totalreclaw-restart` or restart the gateway manually.');
3103
+ '(hooks.allowConversationAccess + channels.telegram.streaming.mode). ' +
3104
+ 'Auto-restarting gateway via SIGUSR1 to apply.');
3105
+ setTimeout(() => {
3106
+ try {
3107
+ process.kill(process.pid, 'SIGUSR1');
3108
+ }
3109
+ catch (err) {
3110
+ const msg = err instanceof Error ? err.message : String(err);
3111
+ api.logger.warn(`TotalReclaw: auto-restart SIGUSR1 emit failed (${msg}). ` +
3112
+ 'Run `/totalreclaw-restart` or restart the gateway manually ' +
3113
+ 'for the patched config to take effect.');
3114
+ }
3115
+ }, 250);
2580
3116
  }
2581
3117
  else if (patchResult === 'error') {
2582
3118
  api.logger.warn('TotalReclaw: failed to auto-patch openclaw.json for OpenClaw 2026.5.x ' +
2583
- 'compatibility. If memory hooks are silently disabled, add these keys ' +
2584
- 'manually: plugins.slots.memory="totalreclaw" and ' +
2585
- 'plugins.entries.totalreclaw.hooks.allowConversationAccess=true.');
3119
+ 'compatibility. If memory hooks are silently disabled, add this key ' +
3120
+ 'manually: plugins.entries.totalreclaw.hooks.allowConversationAccess=true. ' +
3121
+ '(The memory slot is set by OpenClaw itself on plugin install/enable; ' +
3122
+ 'if the slot is wrong, run: openclaw plugins enable totalreclaw)');
2586
3123
  }
2587
3124
  // 'unchanged' and 'skipped' are silent — no log needed.
2588
3125
  }
@@ -2594,6 +3131,20 @@ const plugin = {
2594
3131
  rcMode = false;
2595
3132
  }
2596
3133
  // ---------------------------------------------------------------
3134
+ // Credentials file permission check (cred-1 — fail-closed security gate)
3135
+ // ---------------------------------------------------------------
3136
+ // Must run before any tool registration so that a misconfigured host
3137
+ // is rejected immediately rather than silently operating with an exposed
3138
+ // credentials file. checkCredentialsFileMode returns 'insecure' if the
3139
+ // file mode is broader than 0600 — throw to abort plugin load.
3140
+ {
3141
+ const permResult = checkCredentialsFileMode(CREDENTIALS_PATH, api.logger);
3142
+ if (permResult === 'insecure') {
3143
+ throw new Error(`TotalReclaw refused to load: credentials file has insecure permissions. ` +
3144
+ `Run: chmod 600 "${CREDENTIALS_PATH}" then restart the gateway.`);
3145
+ }
3146
+ }
3147
+ // ---------------------------------------------------------------
2597
3148
  // LLM client initialization (auto-detect provider from OpenClaw config)
2598
3149
  // ---------------------------------------------------------------
2599
3150
  //
@@ -2724,6 +3275,242 @@ const plugin = {
2724
3275
  });
2725
3276
  },
2726
3277
  });
3278
+ // ---------------------------------------------------------------
3279
+ // 3.3.13 — `openclaw totalreclaw import ...` + `upgrade`
3280
+ //
3281
+ // Phase 3.2 retired the totalreclaw_import_from / import_status /
3282
+ // import_abort / upgrade agent tools (recall is native; the rest
3283
+ // became CLI/HTTP surfaces). The handlers stayed in this file
3284
+ // (auto-resume still calls handlePluginImportFrom on gateway
3285
+ // restart) but had NO user-facing entry point — users could not
3286
+ // START a new import, only auto-resume worked. This wiring closes
3287
+ // that gap.
3288
+ //
3289
+ // Why this lives on the `openclaw totalreclaw` subcommand chain
3290
+ // (NOT the standalone `tr` CLI binary): the import handler reaches
3291
+ // module-level state (authKeyHex / encryptionKey / subgraphOwner)
3292
+ // populated by initialize(), plus storeExtractedFacts +
3293
+ // extractFacts + runSmartImportPipeline. The `tr` binary
3294
+ // (tr-cli.ts) is a standalone Node script that does NOT import
3295
+ // the plugin runtime; importing index.ts from it would pull in
3296
+ // the entire gateway runtime. The registerCli subcommand runs
3297
+ // INSIDE the gateway process, so the handlers are directly in
3298
+ // scope — same pattern as `onboard` / `status` / `pair`.
3299
+ //
3300
+ // JSON output: every subcommand accepts --json and emits a single
3301
+ // machine-parseable JSON line on stdout (agent-driven use). Plain
3302
+ // text is for direct user CLI use.
3303
+ //
3304
+ // rc.20 (#402): the import/upgrade wiring below referenced a bare
3305
+ // `tr` that was never declared in THIS callback scope —
3306
+ // registerOnboardingCli and registerPairCli each declare their own
3307
+ // LOCAL `tr`, invisible here. That undeclared reference threw
3308
+ // `ReferenceError: tr is not defined` the moment OpenClaw ran the
3309
+ // callback, killing EVERY `openclaw totalreclaw <sub>` command
3310
+ // (dead since the 3.3.13 import/upgrade restoration; shipped in
3311
+ // rc.19 + rc.20). The build is `tsc --noCheck`, so the type checker
3312
+ // never caught it. Resolve the command group the same way
3313
+ // registerPairCli does — registerOnboardingCli always created it, so
3314
+ // this find() succeeds; the guard is belt-and-braces.
3315
+ const tr = program.commands.find((c) => c.name() === 'totalreclaw');
3316
+ if (!tr) {
3317
+ api.logger.warn('TotalReclaw: `totalreclaw` CLI group not found after onboarding/pair registration — ' +
3318
+ 'skipping import/upgrade wiring. `openclaw totalreclaw import`/`upgrade` will be unavailable.');
3319
+ return;
3320
+ }
3321
+ const importCmd = tr.command('import')
3322
+ .description('Import memories from another tool (Mem0, MCP Memory, ChatGPT, Claude, Gemini). ' +
3323
+ 'Subcommands: `import status`, `import abort`.');
3324
+ importCmd
3325
+ .command('from', { isDefault: true })
3326
+ .description('Start an import from a source tool. Conversation sources (ChatGPT/Claude/Gemini) ' +
3327
+ 'run in the background; poll with `import status`. Pre-structured sources (Mem0/MCP) ' +
3328
+ 'store synchronously.')
3329
+ .argument('<source>', 'mem0 | mcp-memory | chatgpt | claude | gemini')
3330
+ .option('--file <path>', 'Path to the source file on disk')
3331
+ .option('--content <text>', 'Inline source content (JSON/JSONL/CSV/text)')
3332
+ .option('--api-key <key>', 'API key for the source (used once, never stored)')
3333
+ .option('--source-user-id <id>', 'User/agent ID in the source system')
3334
+ .option('--api-url <url>', 'API base URL override (self-hosted instances)')
3335
+ .option('--dry-run', 'Parse + report without storing')
3336
+ .option('--resume <importId>', 'Resume a previously-started import by id')
3337
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3338
+ .action(async (source, opts) => {
3339
+ try {
3340
+ await requireFullSetup(api.logger);
3341
+ const result = await handlePluginImportFrom({
3342
+ source,
3343
+ file_path: opts.file,
3344
+ content: opts.content,
3345
+ api_key: opts.apiKey,
3346
+ source_user_id: opts.sourceUserId,
3347
+ api_url: opts.apiUrl,
3348
+ dry_run: opts.dryRun,
3349
+ resume_id: opts.resume,
3350
+ disclosure_confirmed: true,
3351
+ }, api.logger);
3352
+ if (opts.json) {
3353
+ process.stdout.write(JSON.stringify(result) + '\n');
3354
+ }
3355
+ else {
3356
+ // Human-readable summary. The handler already returns a
3357
+ // `message` for chunked (background) imports; for direct
3358
+ // stores + dry runs, synthesize a short summary.
3359
+ if (result.dry_run) {
3360
+ const chunks = result.total_chunks;
3361
+ if (chunks !== undefined) {
3362
+ process.stdout.write(`Dry run: ~${result.estimated_facts} facts from ${chunks} chunks ` +
3363
+ `(~${result.estimated_minutes} min). Confirm without --dry-run to start.\n`);
3364
+ }
3365
+ else {
3366
+ process.stdout.write(`Dry run: found ${result.total_found} facts. Confirm without --dry-run to import.\n`);
3367
+ }
3368
+ }
3369
+ else if (result.import_id && result.status === 'running') {
3370
+ process.stdout.write(`${result.message}\nImport id: ${result.import_id}\n`);
3371
+ }
3372
+ else {
3373
+ const stored = result.imported;
3374
+ const total = result.total_found;
3375
+ process.stdout.write(`Imported ${stored ?? 0}/${total ?? stored ?? 0} facts from ${source}.\n`);
3376
+ }
3377
+ }
3378
+ }
3379
+ catch (err) {
3380
+ const message = err instanceof Error ? err.message : String(err);
3381
+ if (opts.json) {
3382
+ process.stdout.write(JSON.stringify({ success: false, error: message }) + '\n');
3383
+ }
3384
+ else {
3385
+ process.stderr.write(`import failed: ${message}\n`);
3386
+ }
3387
+ process.exit(1);
3388
+ }
3389
+ });
3390
+ importCmd
3391
+ .command('status')
3392
+ .description('Check progress of a background import. Omit --id for the most recent active import.')
3393
+ .option('--id <importId>', 'Import id (from `import from`). Omit for most-recent active.')
3394
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3395
+ .action(async (opts) => {
3396
+ try {
3397
+ await requireFullSetup(api.logger);
3398
+ const result = await handleImportStatus({ import_id: opts.id }, api.logger);
3399
+ if (opts.json) {
3400
+ process.stdout.write(JSON.stringify(result) + '\n');
3401
+ }
3402
+ else {
3403
+ const status = result.status;
3404
+ const stored = result.facts_stored;
3405
+ const batchDone = result.batch_done;
3406
+ const batchTotal = result.batch_total;
3407
+ if (status === 'no_active_import') {
3408
+ process.stdout.write('No active import. Start one with `openclaw totalreclaw import from <source>`.\n');
3409
+ }
3410
+ else if (status === 'running') {
3411
+ process.stdout.write(`Import ${result.import_id}: running — ${stored} facts stored, ` +
3412
+ `batch ${batchDone}/${batchTotal}` +
3413
+ (result.completion_iso ? `, ETA ${result.completion_iso}` : '') + '.\n');
3414
+ }
3415
+ else {
3416
+ process.stdout.write(`Import ${result.import_id}: ${status} — ${stored ?? 0} facts stored.\n`);
3417
+ }
3418
+ }
3419
+ }
3420
+ catch (err) {
3421
+ const message = err instanceof Error ? err.message : String(err);
3422
+ if (opts.json) {
3423
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
3424
+ }
3425
+ else {
3426
+ process.stderr.write(`import status failed: ${message}\n`);
3427
+ }
3428
+ process.exit(1);
3429
+ }
3430
+ });
3431
+ importCmd
3432
+ .command('abort')
3433
+ .description('Cancel a running background import. Already-stored facts are kept.')
3434
+ .argument('<importId>', 'Import id to abort (from `import from` or `import status`)')
3435
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3436
+ .action(async (importId, opts) => {
3437
+ try {
3438
+ await requireFullSetup(api.logger);
3439
+ const result = await handleImportAbort({ import_id: importId }, api.logger);
3440
+ if (opts.json) {
3441
+ process.stdout.write(JSON.stringify(result) + '\n');
3442
+ }
3443
+ else {
3444
+ if (result.aborted) {
3445
+ process.stdout.write(`Import ${importId}: abort requested. ${result.facts_already_stored ?? 0} facts already stored (kept).\n`);
3446
+ }
3447
+ else {
3448
+ process.stdout.write(`Import ${importId}: ${result.error ?? 'abort failed'}\n`);
3449
+ }
3450
+ }
3451
+ }
3452
+ catch (err) {
3453
+ const message = err instanceof Error ? err.message : String(err);
3454
+ if (opts.json) {
3455
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
3456
+ }
3457
+ else {
3458
+ process.stderr.write(`import abort failed: ${message}\n`);
3459
+ }
3460
+ process.exit(1);
3461
+ }
3462
+ });
3463
+ // `openclaw totalreclaw upgrade` — Stripe checkout URL for Pro.
3464
+ // Restores the retired totalreclaw_upgrade agent tool (cd21176).
3465
+ // Self-contained: POST /v1/billing/checkout → checkout_url.
3466
+ tr.command('upgrade')
3467
+ .description('Get a Stripe checkout URL to upgrade to TotalReclaw Pro (unlimited memories on Gnosis mainnet).')
3468
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3469
+ .action(async (opts) => {
3470
+ try {
3471
+ await requireFullSetup(api.logger);
3472
+ if (!authKeyHex) {
3473
+ throw new Error('Auth credentials are not available. Pair first (`openclaw totalreclaw pair`).');
3474
+ }
3475
+ const walletAddr = subgraphOwner || userId || '';
3476
+ if (!walletAddr) {
3477
+ throw new Error('Wallet address not available. Ensure the plugin is fully initialized.');
3478
+ }
3479
+ const response = await fetch(`${CONFIG.serverUrl}/v1/billing/checkout`, {
3480
+ method: 'POST',
3481
+ headers: buildRelayHeaders({
3482
+ 'Authorization': `Bearer ${authKeyHex}`,
3483
+ 'Content-Type': 'application/json',
3484
+ }),
3485
+ body: JSON.stringify({ wallet_address: walletAddr, tier: 'pro' }),
3486
+ });
3487
+ if (!response.ok) {
3488
+ const body = await response.text().catch(() => '');
3489
+ throw new Error(`checkout session failed (HTTP ${response.status}): ${body || response.statusText}`);
3490
+ }
3491
+ const data = await response.json();
3492
+ if (!data.checkout_url) {
3493
+ throw new Error('no checkout URL returned by the relay');
3494
+ }
3495
+ if (opts.json) {
3496
+ process.stdout.write(JSON.stringify({ checkout_url: data.checkout_url }) + '\n');
3497
+ }
3498
+ else {
3499
+ process.stdout.write(`Open this URL to upgrade to Pro: ${data.checkout_url}\n`);
3500
+ }
3501
+ }
3502
+ catch (err) {
3503
+ const message = err instanceof Error ? err.message : String(err);
3504
+ api.logger.error(`openclaw totalreclaw upgrade failed: ${message}`);
3505
+ if (opts.json) {
3506
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
3507
+ }
3508
+ else {
3509
+ process.stderr.write(`upgrade failed: ${humanizeError(message)}\n`);
3510
+ }
3511
+ process.exit(1);
3512
+ }
3513
+ });
2727
3514
  }, { commands: ['totalreclaw'] });
2728
3515
  }
2729
3516
  else {
@@ -2759,6 +3546,17 @@ const plugin = {
2759
3546
  sessionsPath: CONFIG.pairSessionsPath,
2760
3547
  apiBase: '/plugin/totalreclaw/pair',
2761
3548
  logger: api.logger,
3549
+ // 3.3.14 — wire the relay URL so buildPairRoutes exposes the
3550
+ // in-process `/pair/init` route. The gateway process opens the
3551
+ // relay WebSocket directly (via openRemotePairSession from
3552
+ // pair-remote-client.ts), eliminating the 30s-subprocess-kill
3553
+ // 502 that the CLI path (tr pair) hit when OpenClaw's shell
3554
+ // tool killed the subprocess mid-pair. relayBaseUrl is sourced
3555
+ // from CONFIG.pairRelayUrl (config.ts reads it from the env
3556
+ // once, centrally) — never read from the environment inside
3557
+ // pair-http.ts (scanner-surface rule).
3558
+ relayBaseUrl: CONFIG.pairRelayUrl,
3559
+ initPairMode: 'either',
2762
3560
  validateMnemonic: (p) => validateMnemonic(p, wordlist),
2763
3561
  completePairing: async ({ mnemonic }) => {
2764
3562
  // Write credentials.json + flip state to 'active' via
@@ -2819,7 +3617,20 @@ const plugin = {
2819
3617
  api.registerHttpRoute({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
2820
3618
  api.registerHttpRoute({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
2821
3619
  api.registerHttpRoute({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
2822
- api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously');
3620
+ // 3.3.14 in-process pair trigger. The bundle exposes initPath +
3621
+ // handlers.init ONLY when relayBaseUrl is wired (always true here,
3622
+ // since CONFIG.pairRelayUrl has a built-in default). Registered
3623
+ // with auth: 'plugin' (same as the other pair routes) so the
3624
+ // agent's localhost curl reaches it without a gateway bearer
3625
+ // token. The route opens the relay WS in the gateway process →
3626
+ // survives shell-tool timeouts, retries, SIGUSR1 reloads.
3627
+ if (bundle.initPath && bundle.handlers.init) {
3628
+ api.registerHttpRoute({ path: bundle.initPath, handler: bundle.handlers.init, auth: 'plugin' });
3629
+ api.logger.info('TotalReclaw: registered 5 QR-pairing HTTP routes synchronously (incl. in-process /pair/init)');
3630
+ }
3631
+ else {
3632
+ api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously (in-process /pair/init not wired — no relay URL)');
3633
+ }
2823
3634
  }
2824
3635
  else {
2825
3636
  api.logger.warn('api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
@@ -2901,47 +3712,17 @@ const plugin = {
2901
3712
  };
2902
3713
  }
2903
3714
  if (sub === 'diag') {
2904
- // 3.3.7-rc.1 (issue #216) — diagnostic surface for the
2905
- // tool-binding-on-restart bug. Reports whether the
2906
- // plugin's register() ran in this process (boot count +
2907
- // pid + version + tool count). Non-secret: only public
2908
- // package metadata. The actual filesystem read lives in
2909
- // fs-helpers.readPluginLoadedManifest() so this file
2910
- // stays scanner-clean (whole-file rule disallows fs.read*
2911
- // co-located with `fetch` / `post` markers).
2912
- //
2913
- // Usage: `/totalreclaw diag` from chat OR `cat
2914
- // <pluginDir>/.loaded.json` from the host shell. Both
2915
- // surfaces should agree; if chat says boot=N but the
2916
- // file says boot=N+1, the chat session is stale and a
2917
- // /totalreclaw-restart is warranted.
2918
- try {
2919
- const m = _pluginDirForManifest
2920
- ? readPluginLoadedManifest(_pluginDirForManifest)
2921
- : null;
2922
- if (!m) {
2923
- return {
2924
- text: 'TotalReclaw diag:\n' +
2925
- ` pid=${process.pid}\n` +
2926
- ` version=${pluginVersion ?? 'unknown'}\n` +
2927
- ' loaded-manifest: NOT FOUND (register() may have failed — check .error.json)',
2928
- };
2929
- }
2930
- const stalePid = typeof m.pid === 'number' && m.pid !== process.pid;
2931
- return {
2932
- text: 'TotalReclaw diag:\n' +
2933
- ` current pid=${process.pid}\n` +
2934
- ` manifest pid=${m.pid ?? '?'}${stalePid ? ' (STALE — file from prior boot, register() did NOT run in this process)' : ''}\n` +
2935
- ` version=${m.version ?? 'unknown'}\n` +
2936
- ` boot count=${m.bootCount ?? '?'}\n` +
2937
- ` boot at=${m.bootAt ?? '?'}\n` +
2938
- ` tools registered=${m.tools?.length ?? 0}`,
2939
- };
2940
- }
2941
- catch (err) {
2942
- const msg = err instanceof Error ? err.message : String(err);
2943
- return { text: `TotalReclaw diag: error reading manifest (${msg})` };
2944
- }
3715
+ // Diagnostic surface. The 3.3.7-rc.1 `.loaded.json` manifest
3716
+ // (boot count + pid + tool count) was retired in Phase 3.4 —
3717
+ // the writer was removed in 3.1 and the reader had nothing
3718
+ // current to read. `/totalreclaw diag` now reports pid + the
3719
+ // in-memory plugin version only. For richer boot history,
3720
+ // consult the gateway logs.
3721
+ return {
3722
+ text: 'TotalReclaw diag:\n' +
3723
+ ` pid=${process.pid}\n` +
3724
+ ` version=${pluginVersion ?? 'unknown'}\n`,
3725
+ };
2945
3726
  }
2946
3727
  return {
2947
3728
  text: 'TotalReclaw slash commands:\n' +
@@ -3087,2233 +3868,20 @@ const plugin = {
3087
3868
  return undefined;
3088
3869
  }, { priority: 5 });
3089
3870
  // ---------------------------------------------------------------
3090
- // Tool: totalreclaw_remember
3871
+ // Hook: before_tool_call (memory-tool gate)
3091
3872
  // ---------------------------------------------------------------
3092
- api.registerTool({
3093
- name: 'totalreclaw_remember',
3094
- label: 'Remember',
3095
- description: 'Store a memory in the encrypted vault. Use this when the user shares important information worth remembering.',
3096
- parameters: {
3097
- type: 'object',
3098
- properties: {
3099
- text: {
3100
- type: 'string',
3101
- description: 'The memory text to store',
3102
- },
3103
- type: {
3104
- type: 'string',
3105
- // Dedup the merged enum. `preference` and `summary` appear in
3106
- // BOTH v1 (VALID_MEMORY_TYPES) and legacy v0 (LEGACY_V0_MEMORY_TYPES),
3107
- // so the naive spread produces duplicate items at ## 5 and 12
3108
- // (QA failure on 3.0.7-rc.1: ajv rejects schema with "items ##
3109
- // 5 and 12 are identical"). `new Set(...)` drops dupes while
3110
- // preserving insertion order so v1 tokens appear first in the
3111
- // enum — agents default to picking one of those.
3112
- enum: Array.from(new Set([...VALID_MEMORY_TYPES, ...LEGACY_V0_MEMORY_TYPES])),
3113
- description: 'Memory Taxonomy v1 type: claim, preference, directive, commitment, episode, summary. ' +
3114
- 'Use "claim" for factual assertions and decisions (populate `reasoning` with the why clause). ' +
3115
- 'Use "directive" for imperative rules ("always X", "never Y"), "commitment" for future intent, ' +
3116
- 'and "episode" for notable events. Legacy v0 tokens (fact, decision, episodic, goal, context, ' +
3117
- 'rule) are silently coerced to their v1 equivalents. Default: claim.',
3118
- },
3119
- source: {
3120
- type: 'string',
3121
- enum: [...VALID_MEMORY_SOURCES],
3122
- description: 'v1 provenance tag. "user" = user explicitly stated it, "user-inferred" = inferred from user ' +
3123
- 'signals, "assistant" = assistant-authored (downgrade unless user affirmed), "external" / ' +
3124
- '"derived" = rare. Explicit remembers default to "user".',
3125
- },
3126
- scope: {
3127
- type: 'string',
3128
- enum: [...VALID_MEMORY_SCOPES],
3129
- description: 'v1 life-domain scope: work, personal, health, family, creative, finance, misc, unspecified. ' +
3130
- 'Default: unspecified.',
3131
- },
3132
- reasoning: {
3133
- type: 'string',
3134
- description: 'For type=claim expressing a decision, the WHY clause ("because Y"). Max 256 chars. ' +
3135
- 'Omit for non-decision claims.',
3136
- maxLength: 256,
3137
- },
3138
- importance: {
3139
- type: 'number',
3140
- minimum: 1,
3141
- maximum: 10,
3142
- description: 'Importance score 1-10 (default: 8 for explicit remember)',
3143
- },
3144
- entities: {
3145
- type: 'array',
3146
- description: 'Named entities this memory is about (people, projects, tools, companies, concepts, places). ' +
3147
- 'Supplying entities enables Phase 2 contradiction detection against existing facts about the same entity. ' +
3148
- 'Omit if unclear — a best-effort fallback will still store the memory.',
3149
- items: {
3150
- type: 'object',
3151
- properties: {
3152
- name: { type: 'string' },
3153
- type: {
3154
- type: 'string',
3155
- enum: ['person', 'project', 'tool', 'company', 'concept', 'place'],
3156
- },
3157
- role: { type: 'string' },
3158
- },
3159
- required: ['name', 'type'],
3160
- additionalProperties: false,
3161
- },
3162
- },
3163
- },
3164
- required: ['text'],
3165
- additionalProperties: false,
3166
- },
3167
- async execute(_toolCallId, params) {
3168
- try {
3169
- await requireFullSetup(api.logger);
3170
- // v1 taxonomy: route explicit remembers through the same canonical
3171
- // store path that auto-extraction uses (`storeExtractedFacts`). This
3172
- // emits a Memory Taxonomy v1 JSON blob, generates entity trapdoors,
3173
- // and runs through the Phase 2 contradiction-resolution pipeline.
3174
- //
3175
- // Accept legacy v0 tokens on input and coerce to v1 via
3176
- // `normalizeToV1Type` so agents that still emit the pre-v3
3177
- // taxonomy keep working.
3178
- const rawType = typeof params.type === 'string' ? params.type.toLowerCase() : 'claim';
3179
- const memoryType = isValidMemoryType(rawType)
3180
- ? rawType
3181
- : normalizeToV1Type(rawType);
3182
- // Source defaults to 'user' for explicit remembers (the user is
3183
- // the author by definition). Ignored if the caller passes an
3184
- // invalid value.
3185
- const rawSource = typeof params.source === 'string' ? params.source.toLowerCase() : 'user';
3186
- const memorySource = VALID_MEMORY_SOURCES.includes(rawSource)
3187
- ? rawSource
3188
- : 'user';
3189
- const rawScope = typeof params.scope === 'string' ? params.scope.toLowerCase() : 'unspecified';
3190
- const memoryScope = VALID_MEMORY_SCOPES.includes(rawScope)
3191
- ? rawScope
3192
- : 'unspecified';
3193
- const reasoning = typeof params.reasoning === 'string' && params.reasoning.length > 0
3194
- ? params.reasoning.slice(0, 256)
3195
- : undefined;
3196
- // Explicit remember defaults to importance 8 (above auto-extraction's
3197
- // typical 6-7), so store-time dedup's shouldSupersede prefers the
3198
- // explicit call when it collides with an auto-extracted claim.
3199
- const importance = Math.max(1, Math.min(10, params.importance ?? 8));
3200
- const validatedEntities = Array.isArray(params.entities)
3201
- ? params.entities
3202
- .map((e) => parseEntity(e))
3203
- .filter((e) => e !== null)
3204
- : [];
3205
- const fact = {
3206
- text: params.text.slice(0, 512),
3207
- type: memoryType,
3208
- source: memorySource,
3209
- scope: memoryScope,
3210
- reasoning,
3211
- importance,
3212
- action: 'ADD',
3213
- confidence: 1.0, // user explicitly asked to remember — highest confidence
3214
- };
3215
- if (validatedEntities.length > 0)
3216
- fact.entities = validatedEntities;
3217
- const stored = await storeExtractedFacts([fact], api.logger, 'explicit');
3218
- api.logger.info(`totalreclaw_remember: routed to storeExtractedFacts (stored=${stored}, entities=${validatedEntities.length})`);
3219
- if (stored === 0) {
3220
- // Dedup or supersession consumed the write. Treat as success from
3221
- // the user's perspective — the memory's content is already in the
3222
- // vault (possibly under a different ID).
3223
- return {
3224
- content: [
3225
- {
3226
- type: 'text',
3227
- text: 'Memory noted (matched existing content in vault).',
3228
- },
3229
- ],
3230
- };
3231
- }
3232
- return {
3233
- content: [{ type: 'text', text: 'Memory encrypted and stored.' }],
3234
- };
3235
- }
3236
- catch (err) {
3237
- const message = err instanceof Error ? err.message : String(err);
3238
- api.logger.error(`totalreclaw_remember failed: ${message}`);
3239
- return {
3240
- content: [{ type: 'text', text: `Failed to store memory: ${humanizeError(message)}` }],
3241
- };
3242
- }
3243
- },
3244
- }, { name: 'totalreclaw_remember' });
3245
- // ---------------------------------------------------------------
3246
- // Tool: totalreclaw_recall
3247
- // ---------------------------------------------------------------
3248
- api.registerTool({
3249
- name: 'totalreclaw_recall',
3250
- label: 'Recall',
3251
- description: 'Search the encrypted memory vault. Returns the most relevant memories matching the query.',
3252
- parameters: {
3253
- type: 'object',
3254
- properties: {
3255
- query: {
3256
- type: 'string',
3257
- description: 'Search query text',
3258
- },
3259
- k: {
3260
- type: 'number',
3261
- minimum: 1,
3262
- maximum: 20,
3263
- description: 'Number of results to return (default: 8)',
3264
- },
3265
- },
3266
- required: ['query'],
3267
- additionalProperties: false,
3268
- },
3269
- async execute(_toolCallId, params) {
3270
- try {
3271
- await requireFullSetup(api.logger);
3272
- const k = Math.min(params.k ?? 8, 20);
3273
- // 1. Generate word trapdoors (blind indices for the query).
3274
- const wordTrapdoors = generateBlindIndices(params.query);
3275
- // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
3276
- let queryEmbedding = null;
3277
- let lshTrapdoors = [];
3278
- try {
3279
- queryEmbedding = await generateEmbedding(params.query, { isQuery: true });
3280
- const hasher = getLSHHasher(api.logger);
3281
- if (hasher && queryEmbedding) {
3282
- lshTrapdoors = hasher.hash(queryEmbedding);
3283
- }
3284
- }
3285
- catch (err) {
3286
- const msg = err instanceof Error ? err.message : String(err);
3287
- api.logger.warn(`Recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
3288
- }
3289
- // 3. Merge word trapdoors + LSH trapdoors.
3290
- const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
3291
- if (allTrapdoors.length === 0) {
3292
- return {
3293
- content: [{ type: 'text', text: 'No searchable terms in query.' }],
3294
- details: { count: 0, memories: [] },
3295
- };
3296
- }
3297
- // 4. Request more candidates than needed so we can re-rank client-side.
3298
- // 5. Decrypt candidates (text + embeddings) and build reranker input.
3299
- const rerankerCandidates = [];
3300
- const metaMap = new Map();
3301
- if (isSubgraphMode()) {
3302
- // --- Subgraph search path ---
3303
- const factCount = await getSubgraphFactCount(subgraphOwner || userId, authKeyHex);
3304
- const pool = computeCandidatePool(factCount);
3305
- let subgraphResults = await searchSubgraph(subgraphOwner || userId, allTrapdoors, pool, authKeyHex);
3306
- // Always run broadened search and merge — ensures vocabulary mismatches
3307
- // (e.g., "preferences" vs "prefer") don't cause recall failures.
3308
- // The reranker handles scoring; extra cost is ~1 GraphQL query per recall.
3309
- try {
3310
- const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId, pool, authKeyHex);
3311
- // Merge broadened results with existing (deduplicate by ID)
3312
- const existingIds = new Set(subgraphResults.map(r => r.id));
3313
- for (const br of broadenedResults) {
3314
- if (!existingIds.has(br.id)) {
3315
- subgraphResults.push(br);
3316
- }
3317
- }
3318
- }
3319
- catch { /* best-effort */ }
3320
- for (const result of subgraphResults) {
3321
- try {
3322
- const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
3323
- if (isDigestBlob(docJson))
3324
- continue;
3325
- const doc = readClaimFromBlob(docJson);
3326
- let decryptedEmbedding;
3327
- if (result.encryptedEmbedding) {
3328
- try {
3329
- decryptedEmbedding = JSON.parse(decryptFromHex(result.encryptedEmbedding, encryptionKey));
3330
- }
3331
- catch {
3332
- // Embedding decryption failed -- proceed without it.
3333
- }
3334
- }
3335
- if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
3336
- try {
3337
- decryptedEmbedding = await generateEmbedding(doc.text);
3338
- }
3339
- catch {
3340
- decryptedEmbedding = undefined;
3341
- }
3342
- }
3343
- rerankerCandidates.push({
3344
- id: result.id,
3345
- text: doc.text,
3346
- embedding: decryptedEmbedding,
3347
- importance: doc.importance / 10,
3348
- createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
3349
- // Retrieval v2 Tier 1: surface v1 source so applySourceWeights
3350
- // can multiply the final RRF score by the source weight.
3351
- source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
3352
- });
3353
- metaMap.set(result.id, {
3354
- metadata: doc.metadata ?? {},
3355
- timestamp: Date.now(),
3356
- category: doc.category,
3357
- });
3358
- }
3359
- catch {
3360
- // Skip candidates we cannot decrypt.
3361
- }
3362
- }
3363
- // Update hot cache with top results for instant auto-recall.
3364
- try {
3365
- if (!pluginHotCache && encryptionKey) {
3366
- const config = getSubgraphConfig();
3367
- pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
3368
- pluginHotCache.load();
3369
- }
3370
- if (pluginHotCache) {
3371
- const hotFacts = rerankerCandidates.map((c) => {
3372
- const meta = metaMap.get(c.id);
3373
- const importance = meta?.metadata.importance
3374
- ? Math.round(meta.metadata.importance * 10)
3375
- : 5;
3376
- return { id: c.id, text: c.text, importance };
3377
- });
3378
- pluginHotCache.setHotFacts(hotFacts);
3379
- pluginHotCache.setFactCount(rerankerCandidates.length);
3380
- pluginHotCache.flush();
3381
- }
3382
- }
3383
- catch {
3384
- // Hot cache update is best-effort -- don't fail the recall.
3385
- }
3386
- }
3387
- else {
3388
- // --- Server search path (existing behavior) ---
3389
- const factCount = await getFactCount(api.logger);
3390
- const pool = computeCandidatePool(factCount);
3391
- const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
3392
- for (const candidate of candidates) {
3393
- try {
3394
- const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
3395
- if (isDigestBlob(docJson))
3396
- continue;
3397
- const doc = readClaimFromBlob(docJson);
3398
- let decryptedEmbedding;
3399
- if (candidate.encrypted_embedding) {
3400
- try {
3401
- decryptedEmbedding = JSON.parse(decryptFromHex(candidate.encrypted_embedding, encryptionKey));
3402
- }
3403
- catch {
3404
- // Embedding decryption failed -- proceed without it.
3405
- }
3406
- }
3407
- if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
3408
- try {
3409
- decryptedEmbedding = await generateEmbedding(doc.text);
3410
- }
3411
- catch {
3412
- decryptedEmbedding = undefined;
3413
- }
3414
- }
3415
- rerankerCandidates.push({
3416
- id: candidate.fact_id,
3417
- text: doc.text,
3418
- embedding: decryptedEmbedding,
3419
- importance: doc.importance / 10,
3420
- createdAt: typeof candidate.timestamp === 'number'
3421
- ? candidate.timestamp / 1000
3422
- : new Date(candidate.timestamp).getTime() / 1000,
3423
- source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
3424
- });
3425
- metaMap.set(candidate.fact_id, {
3426
- metadata: doc.metadata ?? {},
3427
- timestamp: candidate.timestamp,
3428
- category: doc.category,
3429
- });
3430
- }
3431
- catch {
3432
- // Skip candidates we cannot decrypt (e.g. corrupted data).
3433
- }
3434
- }
3435
- }
3436
- // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
3437
- const queryIntent = detectQueryIntent(params.query);
3438
- const reranked = rerank(params.query, queryEmbedding ?? [], rerankerCandidates, k, INTENT_WEIGHTS[queryIntent],
3439
- /* applySourceWeights (Retrieval v2 Tier 1) */ true);
3440
- if (reranked.length === 0) {
3441
- return {
3442
- content: [{ type: 'text', text: 'No memories found matching your query.' }],
3443
- details: { count: 0, memories: [] },
3444
- };
3445
- }
3446
- // 6b. Relevance gate removed in rc.22 -- core's intent-weighted
3447
- // RRF + Tier 1 source weighting handles short queries via the
3448
- // BM25 component, making the rc.18 cosine + lexical-override
3449
- // band-aid (issue #116) redundant.
3450
- // 7. Format results.
3451
- const lines = reranked.map((m, i) => {
3452
- const meta = metaMap.get(m.id);
3453
- const imp = meta?.metadata.importance
3454
- ? ` (importance: ${Math.round(meta.metadata.importance * 10)}/10)`
3455
- : '';
3456
- const age = meta ? relativeTime(meta.timestamp) : '';
3457
- const typeTag = meta?.category ? `[${meta.category}] ` : '';
3458
- return `${i + 1}. ${typeTag}${m.text}${imp} -- ${age} [ID: ${m.id}]`;
3459
- });
3460
- const formatted = lines.join('\n');
3461
- return {
3462
- content: [{ type: 'text', text: formatted }],
3463
- details: {
3464
- count: reranked.length,
3465
- memories: reranked.map((m) => ({
3466
- factId: m.id,
3467
- text: m.text,
3468
- })),
3469
- },
3470
- };
3471
- }
3472
- catch (err) {
3473
- const message = err instanceof Error ? err.message : String(err);
3474
- api.logger.error(`totalreclaw_recall failed: ${message}`);
3475
- return {
3476
- content: [{ type: 'text', text: `Failed to search memories: ${humanizeError(message)}` }],
3477
- };
3478
- }
3479
- },
3480
- }, { name: 'totalreclaw_recall' });
3481
- // ---------------------------------------------------------------
3482
- // Tool: totalreclaw_forget
3483
- // ---------------------------------------------------------------
3484
- api.registerTool({
3485
- name: 'totalreclaw_forget',
3486
- label: 'Forget',
3487
- description: 'Delete a specific memory. Use when the user asks to forget, delete, or remove ' +
3488
- 'something specific (e.g. "forget that I live in Porto", "delete the memory about my old job"). ' +
3489
- 'Writes an on-chain tombstone — the delete is permanent and propagates across all devices. ' +
3490
- 'If the user names the memory in natural language instead of an ID, FIRST call ' +
3491
- '`totalreclaw_recall` with their phrase as the query, then pass the top result\'s `id` as ' +
3492
- '`factId`. Non-reversible.',
3493
- parameters: {
3494
- type: 'object',
3495
- properties: {
3496
- factId: {
3497
- type: 'string',
3498
- description: 'The UUID of the memory to delete. Get this from a prior `totalreclaw_recall` result — ' +
3499
- 'the `memories[i].id` field. Never invent a factId; if you don\'t have one, call recall first.',
3500
- },
3501
- },
3502
- required: ['factId'],
3503
- additionalProperties: false,
3504
- },
3505
- async execute(_toolCallId, params) {
3506
- try {
3507
- await requireFullSetup(api.logger);
3508
- // Validate factId shape BEFORE any on-chain work. Prevents
3509
- // silent no-op when the LLM fabricates a non-UUID factId —
3510
- // the classic failure mode from 3.3.1-rc.1 QA where the
3511
- // agent replied "Done" without calling the tool at all, OR
3512
- // called the tool with a plain natural-language string.
3513
- const factId = typeof params.factId === 'string' ? params.factId.trim() : '';
3514
- if (!factId) {
3515
- return {
3516
- content: [{
3517
- type: 'text',
3518
- text: 'Cannot forget without a memory ID. Call `totalreclaw_recall` first with ' +
3519
- 'the user\'s phrasing as the query — the top result\'s `id` field is the ' +
3520
- 'factId to pass here.',
3521
- }],
3522
- details: { deleted: false, error: 'missing-fact-id' },
3523
- };
3524
- }
3525
- // UUID-v4-ish shape check (loose — accepts any hex-dashed id).
3526
- // Prevents cases like `factId: "that I live in Porto"` from
3527
- // reaching the UserOp path and silently failing on-chain.
3528
- const looksLikeFactId = /^[0-9a-f-]{8,}$/i.test(factId);
3529
- if (!looksLikeFactId) {
3530
- api.logger.warn(`totalreclaw_forget: rejected likely-invalid factId "${factId.slice(0, 40)}" ` +
3531
- `— expected a UUID from a prior recall result, not natural language.`);
3532
- return {
3533
- content: [{
3534
- type: 'text',
3535
- text: `"${factId.slice(0, 60)}" doesn\'t look like a memory ID. Call ` +
3536
- '`totalreclaw_recall` first with the user\'s phrasing as the query, then ' +
3537
- 'pass the top result\'s `id` field (a hex UUID) as `factId`.',
3538
- }],
3539
- details: { deleted: false, error: 'invalid-fact-id' },
3540
- };
3541
- }
3542
- if (isSubgraphMode()) {
3543
- // On-chain tombstone: write a minimal protobuf with decayScore=0
3544
- // The subgraph picks this up and sets isActive=false.
3545
- //
3546
- // 3.3.1-rc.2 fix: route through submitFactBatchOnChain with a
3547
- // single-payload batch so we share the tombstone codepath the
3548
- // pin/unpin flow uses (that flow is known-good and the QA
3549
- // confirms pin works). Also write at legacy v3 (NOT v4) so the
3550
- // subgraph handler matches the source="tombstone" + version=3
3551
- // shape the contradiction/pin tombstones use.
3552
- const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex, walletAddress: subgraphOwner ?? undefined };
3553
- const tombstone = {
3554
- id: factId,
3555
- timestamp: new Date().toISOString(),
3556
- owner: subgraphOwner || userId,
3557
- encryptedBlob: '00', // minimal 1-byte placeholder
3558
- blindIndices: [],
3559
- decayScore: 0,
3560
- source: 'tombstone',
3561
- contentFp: '',
3562
- agentId: 'openclaw-plugin-forget',
3563
- // Deliberately NO version: field → uses the default (legacy v3).
3564
- // The pin/unpin tombstones use v3 (see pin.ts:611-621) — we
3565
- // MUST match that shape or the subgraph may not flip isActive.
3566
- };
3567
- const protobuf = encodeFactProtobuf(tombstone);
3568
- const result = await submitFactBatchOnChain([protobuf], config);
3569
- if (!result.success) {
3570
- throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
3571
- }
3572
- api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
3573
- // Read-after-write: poll the subgraph until the original fact id
3574
- // is no longer active (forget flips isActive=false). On timeout
3575
- // surface `partial: true` so the agent can explain the chain
3576
- // write succeeded but the subgraph is still propagating.
3577
- const confirm = await confirmIndexed(factId, {
3578
- expect: 'inactive',
3579
- authKeyHex: authKeyHex,
3580
- });
3581
- return {
3582
- content: [{
3583
- type: 'text',
3584
- text: confirm.indexed
3585
- ? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
3586
- : `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
3587
- 'The subgraph indexer is still propagating the change — ' +
3588
- 'recall/export may briefly show the memory as still active.',
3589
- }],
3590
- details: {
3591
- deleted: true,
3592
- txHash: result.txHash,
3593
- factId,
3594
- ...(confirm.indexed ? {} : { partial: true }),
3595
- },
3596
- };
3597
- }
3598
- else {
3599
- await apiClient.deleteFact(factId, authKeyHex);
3600
- return {
3601
- content: [{ type: 'text', text: `Memory ${factId} deleted` }],
3602
- details: { deleted: true, factId },
3603
- };
3604
- }
3605
- }
3606
- catch (err) {
3607
- const message = err instanceof Error ? err.message : String(err);
3608
- api.logger.error(`totalreclaw_forget failed: ${message}`);
3609
- return {
3610
- content: [{ type: 'text', text: `Failed to delete memory: ${humanizeError(message)}` }],
3611
- };
3612
- }
3613
- },
3614
- }, { name: 'totalreclaw_forget' });
3615
- // ---------------------------------------------------------------
3616
- // Tool: totalreclaw_export
3617
- // ---------------------------------------------------------------
3618
- api.registerTool({
3619
- name: 'totalreclaw_export',
3620
- label: 'Export',
3621
- description: 'Export all stored memories. Decrypts every memory and returns them as JSON or Markdown.',
3622
- parameters: {
3623
- type: 'object',
3624
- properties: {
3625
- format: {
3626
- type: 'string',
3627
- enum: ['json', 'markdown'],
3628
- description: 'Output format (default: json)',
3629
- },
3630
- },
3631
- additionalProperties: false,
3632
- },
3633
- async execute(_toolCallId, params) {
3634
- try {
3635
- await requireFullSetup(api.logger);
3636
- const format = params.format ?? 'json';
3637
- // Paginate through all facts.
3638
- const allFacts = [];
3639
- if (isSubgraphMode()) {
3640
- // Query subgraph for all active facts (cursor-based pagination via id_gt)
3641
- const config = getSubgraphConfig();
3642
- const relayUrl = config.relayUrl;
3643
- const PAGE_SIZE = 1000;
3644
- let lastId = '';
3645
- const owner = subgraphOwner || userId || '';
3646
- console.error(`[TotalReclaw Export] owner=${owner} subgraphOwner=${subgraphOwner} userId=${userId} relayUrl=${relayUrl} authKey=${authKeyHex ? authKeyHex.slice(0, 8) + '...' : 'MISSING'} isSubgraph=${isSubgraphMode()}`);
3647
- while (true) {
3648
- const hasLastId = lastId !== '';
3649
- const query = hasLastId
3650
- ? `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}}`
3651
- : `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`;
3652
- const variables = hasLastId
3653
- ? { owner, first: PAGE_SIZE, lastId }
3654
- : { owner, first: PAGE_SIZE };
3655
- const res = await fetch(`${relayUrl}/v1/subgraph`, {
3656
- method: 'POST',
3657
- headers: buildRelayHeaders({
3658
- 'Content-Type': 'application/json',
3659
- ...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
3660
- }),
3661
- body: JSON.stringify({ query, variables }),
3662
- });
3663
- const json = (await res.json());
3664
- // Surface relay/subgraph errors instead of silently returning empty
3665
- if (json.error || json.errors) {
3666
- const errMsg = json.error || json.errors?.map(e => e.message).join('; ') || 'Unknown error';
3667
- api.logger.error(`Export subgraph query failed: ${errMsg} (owner=${owner}, status=${res.status})`);
3668
- return {
3669
- content: [{ type: 'text', text: `Export failed: ${errMsg}` }],
3670
- };
3671
- }
3672
- const facts = json?.data?.facts || [];
3673
- if (facts.length === 0)
3674
- break;
3675
- for (const fact of facts) {
3676
- try {
3677
- let hexBlob = fact.encryptedBlob;
3678
- if (hexBlob.startsWith('0x'))
3679
- hexBlob = hexBlob.slice(2);
3680
- const docJson = decryptFromHex(hexBlob, encryptionKey);
3681
- if (isDigestBlob(docJson))
3682
- continue;
3683
- const doc = readClaimFromBlob(docJson);
3684
- allFacts.push({
3685
- id: fact.id,
3686
- text: doc.text,
3687
- metadata: doc.metadata,
3688
- created_at: new Date(parseInt(fact.timestamp) * 1000).toISOString(),
3689
- });
3690
- }
3691
- catch {
3692
- // Skip facts we cannot decrypt
3693
- }
3694
- }
3695
- if (facts.length < PAGE_SIZE)
3696
- break;
3697
- lastId = facts[facts.length - 1].id;
3698
- }
3699
- }
3700
- else {
3701
- // HTTP server mode — paginate through PostgreSQL facts
3702
- let cursor;
3703
- let hasMore = true;
3704
- while (hasMore) {
3705
- const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
3706
- for (const fact of page.facts) {
3707
- try {
3708
- const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
3709
- if (isDigestBlob(docJson))
3710
- continue;
3711
- const doc = readClaimFromBlob(docJson);
3712
- allFacts.push({
3713
- id: fact.id,
3714
- text: doc.text,
3715
- metadata: doc.metadata,
3716
- created_at: fact.created_at,
3717
- });
3718
- }
3719
- catch {
3720
- // Skip facts we cannot decrypt.
3721
- }
3722
- }
3723
- cursor = page.cursor ?? undefined;
3724
- hasMore = page.has_more;
3725
- }
3726
- }
3727
- // Format output.
3728
- let formatted;
3729
- if (format === 'markdown') {
3730
- if (allFacts.length === 0) {
3731
- formatted = '*No memories stored.*';
3732
- }
3733
- else {
3734
- const lines = allFacts.map((f, i) => {
3735
- const meta = f.metadata;
3736
- const type = meta.type ?? 'fact';
3737
- const imp = meta.importance
3738
- ? ` (importance: ${Math.round(meta.importance * 10)}/10)`
3739
- : '';
3740
- return `${i + 1}. **[${type}]** ${f.text}${imp} \n _ID: ${f.id} | Created: ${f.created_at}_`;
3741
- });
3742
- formatted = `# Exported Memories (${allFacts.length})\n\n${lines.join('\n')}`;
3743
- }
3744
- }
3745
- else {
3746
- formatted = JSON.stringify(allFacts, null, 2);
3747
- }
3748
- return {
3749
- content: [{ type: 'text', text: formatted }],
3750
- details: { count: allFacts.length },
3751
- };
3752
- }
3753
- catch (err) {
3754
- const message = err instanceof Error ? err.message : String(err);
3755
- api.logger.error(`totalreclaw_export failed: ${message}`);
3756
- return {
3757
- content: [{ type: 'text', text: `Failed to export memories: ${humanizeError(message)}` }],
3758
- };
3759
- }
3760
- },
3761
- }, { name: 'totalreclaw_export' });
3762
- // ---------------------------------------------------------------
3763
- // Tool: totalreclaw_status
3764
- // ---------------------------------------------------------------
3765
- api.registerTool({
3766
- name: 'totalreclaw_status',
3767
- label: 'Status',
3768
- description: 'Check TotalReclaw billing and subscription status — tier, writes used, reset date.',
3769
- parameters: {
3770
- type: 'object',
3771
- properties: {},
3772
- additionalProperties: false,
3773
- },
3774
- async execute() {
3775
- try {
3776
- await requireFullSetup(api.logger);
3777
- if (!authKeyHex) {
3778
- return {
3779
- content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
3780
- };
3781
- }
3782
- const serverUrl = CONFIG.serverUrl;
3783
- const walletAddr = subgraphOwner || userId || '';
3784
- const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
3785
- method: 'GET',
3786
- headers: buildRelayHeaders({
3787
- 'Authorization': `Bearer ${authKeyHex}`,
3788
- 'Accept': 'application/json',
3789
- }),
3790
- });
3791
- if (!response.ok) {
3792
- const body = await response.text().catch(() => '');
3793
- return {
3794
- content: [{ type: 'text', text: `Failed to fetch billing status (HTTP ${response.status}): ${body || response.statusText}` }],
3795
- };
3796
- }
3797
- const data = await response.json();
3798
- const tier = data.tier || 'free';
3799
- const freeWritesUsed = data.free_writes_used ?? 0;
3800
- const freeWritesLimit = data.free_writes_limit ?? 0;
3801
- const freeWritesResetAt = data.free_writes_reset_at;
3802
- // Update billing cache on success.
3803
- writeBillingCache({
3804
- tier,
3805
- free_writes_used: freeWritesUsed,
3806
- free_writes_limit: freeWritesLimit,
3807
- features: data.features,
3808
- checked_at: Date.now(),
3809
- });
3810
- // 3.3.1 (internal#130) — surface the Smart Account / scope
3811
- // address so the user can do subgraph queries, BaseScan
3812
- // lookups, and cross-client portability checks BEFORE any
3813
- // chain write completes. Resolution priority:
3814
- // 1. In-memory `subgraphOwner` (already derived earlier).
3815
- // 2. credentials.json `scope_address` (persisted at pair).
3816
- // 3. Lazy derive now from the loaded mnemonic + cache it
3817
- // back to credentials.json so the next call is free.
3818
- let scopeAddress = subgraphOwner ?? undefined;
3819
- if (!scopeAddress) {
3820
- try {
3821
- const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
3822
- if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
3823
- scopeAddress = credsCache.scope_address;
3824
- }
3825
- else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
3826
- scopeAddress = await deriveSmartAccountAddress(credsCache.mnemonic, CONFIG.chainId);
3827
- if (scopeAddress) {
3828
- writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
3829
- }
3830
- }
3831
- }
3832
- catch (deriveErr) {
3833
- api.logger.warn(`totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
3834
- }
3835
- }
3836
- const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
3837
- const lines = [
3838
- `Tier: ${tierLabel}`,
3839
- `Writes: ${freeWritesUsed}/${freeWritesLimit} used this month`,
3840
- ];
3841
- if (freeWritesResetAt) {
3842
- lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
3843
- }
3844
- if (scopeAddress) {
3845
- lines.push(`Smart Account: ${scopeAddress}`);
3846
- }
3847
- if (tier !== 'pro') {
3848
- lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
3849
- }
3850
- return {
3851
- content: [{ type: 'text', text: lines.join('\n') }],
3852
- details: {
3853
- tier,
3854
- free_writes_used: freeWritesUsed,
3855
- free_writes_limit: freeWritesLimit,
3856
- scope_address: scopeAddress,
3857
- },
3858
- };
3859
- }
3860
- catch (err) {
3861
- const message = err instanceof Error ? err.message : String(err);
3862
- api.logger.error(`totalreclaw_status failed: ${message}`);
3863
- return {
3864
- content: [{ type: 'text', text: `Failed to check status: ${humanizeError(message)}` }],
3865
- };
3866
- }
3867
- },
3868
- }, { name: 'totalreclaw_status' });
3869
- // ---------------------------------------------------------------
3870
- // Tool: totalreclaw_preload_embedder (3.3.3-rc.1 — issue #187)
3871
- // ---------------------------------------------------------------
3872
- //
3873
- // Decouples the ~700 MB embedder bundle download from the pair-
3874
- // completion gate. The agent can call this BEFORE
3875
- // `totalreclaw_pair` to pre-flight disk space, kick off the
3876
- // download, and surface the completion state. The non-blocking
3877
- // prefetch in `register()` already starts the download
3878
- // unconditionally; this tool is an explicit on-demand hook for
3879
- // agents that want to confirm completion (or trigger a retry on
3880
- // network failure) without first completing pair.
3881
- //
3882
- // Behavior:
3883
- // - Disk-space pre-flight: refuses if free disk < 500 MB on the
3884
- // embedder cache mount. Surfaces the path + free bytes so the
3885
- // user can clear space.
3886
- // - Triggers `prefetchEmbedderBundle()` (idempotent — cache hit
3887
- // returns immediately).
3888
- // - Returns a structured success message with status:
3889
- // `cache_hit | fetched | failed`.
3890
- //
3891
- // Phrase-safety: this tool does NOT touch credentials.json,
3892
- // mnemonics, or keys. It only touches `~/.totalreclaw/embedder/`
3893
- // and the GitHub Releases CDN.
3894
- api.registerTool({
3895
- name: 'totalreclaw_preload_embedder',
3896
- label: 'Preload Embedder',
3897
- 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/.',
3898
- parameters: {
3899
- type: 'object',
3900
- properties: {},
3901
- additionalProperties: false,
3902
- },
3903
- async execute() {
3904
- try {
3905
- // Disk-space pre-flight: refuse if < 500 MB free on the
3906
- // embedder cache mount. Best-effort — if statfs fails, we
3907
- // proceed without the pre-flight rather than blocking.
3908
- const fsModule = await import('node:fs');
3909
- const cacheRoot = CONFIG.embedderCachePath;
3910
- const REQUIRED_BYTES = 500 * 1024 * 1024;
3911
- try {
3912
- // Find the deepest existing parent so statfs has a real
3913
- // mount to measure (loadEmbedder will mkdir under it
3914
- // anyway).
3915
- let probeDir = cacheRoot;
3916
- while (true) {
3917
- try {
3918
- fsModule.statSync(probeDir);
3919
- break;
3920
- }
3921
- catch {
3922
- const parent = nodePath.dirname(probeDir);
3923
- if (parent === probeDir)
3924
- break;
3925
- probeDir = parent;
3926
- }
3927
- }
3928
- const stats = fsModule.statfsSync?.(probeDir);
3929
- if (stats) {
3930
- const bavail = typeof stats.bavail === 'bigint' ? Number(stats.bavail) : stats.bavail;
3931
- const bsize = typeof stats.bsize === 'bigint' ? Number(stats.bsize) : stats.bsize;
3932
- const freeBytes = bavail * bsize;
3933
- if (freeBytes > 0 && freeBytes < REQUIRED_BYTES) {
3934
- const freeMb = Math.round(freeBytes / (1024 * 1024));
3935
- return {
3936
- content: [{
3937
- type: 'text',
3938
- text: `Insufficient free disk space for embedder bundle. Required: 500 MB. Available at ${probeDir}: ${freeMb} MB. Free up space and retry.`,
3939
- }],
3940
- details: { status: 'failed', reason: 'disk_space', free_mb: freeMb, required_mb: 500, cache_root: cacheRoot },
3941
- };
3942
- }
3943
- }
3944
- }
3945
- catch {
3946
- // statfs probe failed — surface a soft warning in logs
3947
- // but proceed with the download anyway.
3948
- api.logger.info('totalreclaw_preload_embedder: disk-space probe unavailable, proceeding without pre-flight');
3949
- }
3950
- // Trigger the prefetch. This is idempotent (cache hit returns
3951
- // immediately) so it's safe to invoke even when the
3952
- // background prefetch from register() already completed.
3953
- const result = await prefetchEmbedderBundle({
3954
- log: (msg) => api.logger.info(msg),
3955
- });
3956
- const human = result === 'fetched'
3957
- ? `Embedder bundle downloaded and cached at ${cacheRoot}. Subsequent embedding calls run in-memory.`
3958
- : `Embedder bundle already cached at ${cacheRoot} — no download needed.`;
3959
- return {
3960
- content: [{ type: 'text', text: human }],
3961
- details: { status: result, cache_root: cacheRoot },
3962
- };
3963
- }
3964
- catch (err) {
3965
- const message = err instanceof Error ? err.message : String(err);
3966
- api.logger.error(`totalreclaw_preload_embedder failed: ${message}`);
3967
- return {
3968
- content: [{
3969
- type: 'text',
3970
- text: `Embedder bundle preload failed: ${humanizeError(message)}. The plugin will retry on first embedding call.`,
3971
- }],
3972
- details: { status: 'failed', reason: 'fetch_error', message },
3973
- };
3974
- }
3975
- },
3976
- }, { name: 'totalreclaw_preload_embedder' });
3977
- // ---------------------------------------------------------------
3978
- // Tool: totalreclaw_consolidate
3979
- // ---------------------------------------------------------------
3980
- api.registerTool({
3981
- name: 'totalreclaw_consolidate',
3982
- label: 'Consolidate',
3983
- description: 'Deduplicate and merge related memories. Self-hosted mode only.',
3984
- parameters: {
3985
- type: 'object',
3986
- properties: {
3987
- dry_run: {
3988
- type: 'boolean',
3989
- description: 'Preview only (default: false)',
3990
- },
3991
- },
3992
- additionalProperties: false,
3993
- },
3994
- async execute(_toolCallId, params) {
3995
- try {
3996
- await requireFullSetup(api.logger);
3997
- const dryRun = params.dry_run ?? false;
3998
- // Consolidation is only available in centralized (HTTP server) mode.
3999
- if (isSubgraphMode()) {
4000
- return {
4001
- content: [{ type: 'text', text: 'Consolidation is currently only available in centralized mode.' }],
4002
- };
4003
- }
4004
- if (!apiClient || !authKeyHex || !encryptionKey) {
4005
- return {
4006
- content: [{ type: 'text', text: 'Plugin not fully initialized. Cannot consolidate.' }],
4007
- };
4008
- }
4009
- // 1. Export all facts (paginated, max 10 pages of 1000).
4010
- const allDecrypted = [];
4011
- let cursor;
4012
- let hasMore = true;
4013
- let pageCount = 0;
4014
- const MAX_PAGES = 10;
4015
- while (hasMore && pageCount < MAX_PAGES) {
4016
- const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
4017
- for (const fact of page.facts) {
4018
- try {
4019
- const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
4020
- if (isDigestBlob(docJson))
4021
- continue;
4022
- const doc = readClaimFromBlob(docJson);
4023
- let embedding = null;
4024
- try {
4025
- embedding = await generateEmbedding(doc.text);
4026
- }
4027
- catch { /* skip — fact will not be clustered */ }
4028
- allDecrypted.push({
4029
- id: fact.id,
4030
- text: doc.text,
4031
- embedding,
4032
- importance: doc.importance,
4033
- decayScore: fact.decay_score,
4034
- createdAt: new Date(fact.created_at).getTime(),
4035
- version: fact.version,
4036
- });
4037
- }
4038
- catch {
4039
- // Skip undecryptable facts.
4040
- }
4041
- }
4042
- cursor = page.cursor ?? undefined;
4043
- hasMore = page.has_more;
4044
- pageCount++;
4045
- }
4046
- if (allDecrypted.length === 0) {
4047
- return {
4048
- content: [{ type: 'text', text: 'No memories found to consolidate.' }],
4049
- };
4050
- }
4051
- // 2. Cluster by cosine similarity.
4052
- const clusters = clusterFacts(allDecrypted, getConsolidationThreshold());
4053
- if (clusters.length === 0) {
4054
- return {
4055
- content: [{ type: 'text', text: `Scanned ${allDecrypted.length} memories — no near-duplicates found.` }],
4056
- };
4057
- }
4058
- // 3. Build report.
4059
- const totalDuplicates = clusters.reduce((sum, c) => sum + c.duplicates.length, 0);
4060
- const reportLines = [
4061
- `Scanned ${allDecrypted.length} memories.`,
4062
- `Found ${clusters.length} cluster(s) with ${totalDuplicates} duplicate(s).`,
4063
- '',
4064
- ];
4065
- const displayClusters = clusters.slice(0, 10);
4066
- for (let i = 0; i < displayClusters.length; i++) {
4067
- const cluster = displayClusters[i];
4068
- reportLines.push(`Cluster ${i + 1}: KEEP "${cluster.representative.text.slice(0, 80)}…"`);
4069
- for (const dup of cluster.duplicates) {
4070
- reportLines.push(` - REMOVE "${dup.text.slice(0, 80)}…" (ID: ${dup.id})`);
4071
- }
4072
- }
4073
- if (clusters.length > 10) {
4074
- reportLines.push(`... and ${clusters.length - 10} more cluster(s).`);
4075
- }
4076
- // 4. If not dry_run, batch-delete duplicates.
4077
- if (!dryRun) {
4078
- const idsToDelete = clusters.flatMap((c) => c.duplicates.map((d) => d.id));
4079
- const BATCH_SIZE = 500;
4080
- let totalDeleted = 0;
4081
- for (let i = 0; i < idsToDelete.length; i += BATCH_SIZE) {
4082
- const batch = idsToDelete.slice(i, i + BATCH_SIZE);
4083
- const deleted = await apiClient.batchDelete(batch, authKeyHex);
4084
- totalDeleted += deleted;
4085
- }
4086
- reportLines.push('');
4087
- reportLines.push(`Deleted ${totalDeleted} duplicate memories.`);
4088
- }
4089
- else {
4090
- reportLines.push('');
4091
- reportLines.push('DRY RUN — no memories were deleted. Run without dry_run to apply.');
4092
- }
4093
- return {
4094
- content: [{ type: 'text', text: reportLines.join('\n') }],
4095
- details: {
4096
- scanned: allDecrypted.length,
4097
- clusters: clusters.length,
4098
- duplicates: totalDuplicates,
4099
- dry_run: dryRun,
4100
- },
4101
- };
4102
- }
4103
- catch (err) {
4104
- const message = err instanceof Error ? err.message : String(err);
4105
- api.logger.error(`totalreclaw_consolidate failed: ${message}`);
4106
- return {
4107
- content: [{ type: 'text', text: `Failed to consolidate memories: ${humanizeError(message)}` }],
4108
- };
4109
- }
4110
- },
4111
- }, { name: 'totalreclaw_consolidate' });
4112
- // ---------------------------------------------------------------
4113
- // Helper: build PinOpDeps bound to the live plugin state
4114
- // ---------------------------------------------------------------
4115
- // Wires the pure pin/unpin operation to the managed-service transport +
4116
- // crypto layer. Mirrors MCP's buildPinDepsFromState and Python's
4117
- // _change_claim_status argument plumbing.
4118
- const buildPinDeps = () => {
4119
- const owner = subgraphOwner || userId || '';
4120
- const config = {
4121
- ...getSubgraphConfig(),
4122
- authKeyHex: authKeyHex,
4123
- walletAddress: subgraphOwner ?? undefined,
4124
- };
4125
- return {
4126
- owner,
4127
- sourceAgent: 'openclaw-plugin',
4128
- fetchFactById: (factId) => fetchFactById(owner, factId, authKeyHex),
4129
- decryptBlob: (hex) => decryptFromHex(hex, encryptionKey),
4130
- encryptBlob: (plaintext) => encryptToHex(plaintext, encryptionKey),
4131
- submitBatch: async (payloads) => {
4132
- const result = await submitFactBatchOnChain(payloads, config);
4133
- return { txHash: result.txHash, success: result.success };
4134
- },
4135
- generateIndices: async (text, entityNames) => {
4136
- if (!text)
4137
- return { blindIndices: [] };
4138
- const wordIndices = generateBlindIndices(text);
4139
- let lshIndices = [];
4140
- let encryptedEmbedding;
4141
- try {
4142
- const embedding = await generateEmbedding(text);
4143
- const hasher = getLSHHasher(api.logger);
4144
- if (hasher)
4145
- lshIndices = hasher.hash(embedding);
4146
- encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey);
4147
- }
4148
- catch {
4149
- // Best-effort: word + entity trapdoors alone still surface the claim.
4150
- }
4151
- const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
4152
- return {
4153
- blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
4154
- encryptedEmbedding,
4155
- };
4156
- },
4157
- };
4158
- };
4159
- // ---------------------------------------------------------------
4160
- // Tool: totalreclaw_pin
4161
- // ---------------------------------------------------------------
4162
- api.registerTool({
4163
- name: 'totalreclaw_pin',
4164
- label: 'Pin',
4165
- description: 'Pin a memory so the auto-resolution engine will never override or supersede it. ' +
4166
- "Use when the user explicitly confirms a claim is still valid after you or another agent " +
4167
- "tried to retract/contradict it (e.g. 'wait, I still use Vim sometimes'). " +
4168
- 'Takes fact_id (from a prior recall result). Pinning is idempotent — pinning an already-pinned ' +
4169
- 'claim is a no-op. Cross-device: the pin propagates via the on-chain supersession chain.',
4170
- parameters: {
4171
- type: 'object',
4172
- properties: {
4173
- fact_id: {
4174
- type: 'string',
4175
- description: 'The ID of the fact to pin (from a totalreclaw_recall result).',
4176
- },
4177
- reason: {
4178
- type: 'string',
4179
- description: 'Optional human-readable reason for pinning (logged locally for tuning).',
4180
- },
4181
- },
4182
- required: ['fact_id'],
4183
- additionalProperties: false,
4184
- },
4185
- async execute(_toolCallId, params) {
4186
- try {
4187
- await requireFullSetup(api.logger);
4188
- if (!isSubgraphMode()) {
4189
- return {
4190
- content: [{
4191
- type: 'text',
4192
- text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4193
- }],
4194
- };
4195
- }
4196
- const validation = validatePinArgs(params);
4197
- if (!validation.ok) {
4198
- return { content: [{ type: 'text', text: validation.error }] };
4199
- }
4200
- const deps = buildPinDeps();
4201
- const result = await executePinOperation(validation.factId, 'pinned', deps, validation.reason);
4202
- if (result.success && result.idempotent) {
4203
- api.logger.info(`totalreclaw_pin: ${result.fact_id} already pinned (no-op)`);
4204
- return {
4205
- content: [{ type: 'text', text: `Memory ${result.fact_id} is already pinned.` }],
4206
- details: result,
4207
- };
4208
- }
4209
- if (result.success) {
4210
- api.logger.info(`totalreclaw_pin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
4211
- return {
4212
- content: [{
4213
- type: 'text',
4214
- text: `Pinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4215
- }],
4216
- details: result,
4217
- };
4218
- }
4219
- api.logger.error(`totalreclaw_pin failed: ${result.error}`);
4220
- return {
4221
- content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
4222
- details: result,
4223
- };
4224
- }
4225
- catch (err) {
4226
- const message = err instanceof Error ? err.message : String(err);
4227
- api.logger.error(`totalreclaw_pin failed: ${message}`);
4228
- return {
4229
- content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(message)}` }],
4230
- };
4231
- }
4232
- },
4233
- }, { name: 'totalreclaw_pin' });
4234
- // ---------------------------------------------------------------
4235
- // Tool: totalreclaw_unpin
4236
- // ---------------------------------------------------------------
4237
- api.registerTool({
4238
- name: 'totalreclaw_unpin',
4239
- label: 'Unpin',
4240
- description: 'Remove the pin from a previously pinned memory, returning it to active status so the ' +
4241
- 'auto-resolution engine can supersede or retract it again. Takes fact_id. Idempotent — ' +
4242
- 'unpinning a non-pinned claim is a no-op.',
4243
- parameters: {
4244
- type: 'object',
4245
- properties: {
4246
- fact_id: {
4247
- type: 'string',
4248
- description: 'The ID of the fact to unpin (from a totalreclaw_recall result).',
4249
- },
4250
- },
4251
- required: ['fact_id'],
4252
- additionalProperties: false,
4253
- },
4254
- async execute(_toolCallId, params) {
4255
- try {
4256
- await requireFullSetup(api.logger);
4257
- if (!isSubgraphMode()) {
4258
- return {
4259
- content: [{
4260
- type: 'text',
4261
- text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4262
- }],
4263
- };
4264
- }
4265
- const validation = validatePinArgs(params);
4266
- if (!validation.ok) {
4267
- return { content: [{ type: 'text', text: validation.error }] };
4268
- }
4269
- const deps = buildPinDeps();
4270
- const result = await executePinOperation(validation.factId, 'active', deps);
4271
- if (result.success && result.idempotent) {
4272
- api.logger.info(`totalreclaw_unpin: ${result.fact_id} already active (no-op)`);
4273
- return {
4274
- content: [{ type: 'text', text: `Memory ${result.fact_id} is not pinned.` }],
4275
- details: result,
4276
- };
4277
- }
4278
- if (result.success) {
4279
- api.logger.info(`totalreclaw_unpin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
4280
- return {
4281
- content: [{
4282
- type: 'text',
4283
- text: `Unpinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4284
- }],
4285
- details: result,
4286
- };
4287
- }
4288
- api.logger.error(`totalreclaw_unpin failed: ${result.error}`);
4289
- return {
4290
- content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
4291
- details: result,
4292
- };
4293
- }
4294
- catch (err) {
4295
- const message = err instanceof Error ? err.message : String(err);
4296
- api.logger.error(`totalreclaw_unpin failed: ${message}`);
4297
- return {
4298
- content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(message)}` }],
4299
- };
4300
- }
4301
- },
4302
- }, { name: 'totalreclaw_unpin' });
4303
- // ---------------------------------------------------------------
4304
- // Shared deps for retype + set_scope (same shape as pin deps).
4305
- // Built lazily so the closure captures the current encryption key /
4306
- // subgraph owner at call time rather than at register() time.
4307
- // ---------------------------------------------------------------
4308
- const buildRetypeSetScopeDeps = () => {
4309
- const owner = subgraphOwner || userId || '';
4310
- const config = {
4311
- ...getSubgraphConfig(),
4312
- authKeyHex: authKeyHex,
4313
- walletAddress: subgraphOwner ?? undefined,
4314
- };
4315
- return {
4316
- owner,
4317
- sourceAgent: 'openclaw-plugin',
4318
- fetchFactById: (factId) => fetchFactById(owner, factId, authKeyHex),
4319
- decryptBlob: (hex) => decryptFromHex(hex, encryptionKey),
4320
- encryptBlob: (plaintext) => encryptToHex(plaintext, encryptionKey),
4321
- submitBatch: async (payloads) => {
4322
- const result = await submitFactBatchOnChain(payloads, config);
4323
- return { txHash: result.txHash, success: result.success };
4324
- },
4325
- generateIndices: async (text, entityNames) => {
4326
- if (!text)
4327
- return { blindIndices: [] };
4328
- const wordIndices = generateBlindIndices(text);
4329
- let lshIndices = [];
4330
- let encryptedEmbedding;
4331
- try {
4332
- const embedding = await generateEmbedding(text);
4333
- const hasher = getLSHHasher(api.logger);
4334
- if (hasher)
4335
- lshIndices = hasher.hash(embedding);
4336
- encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey);
4337
- }
4338
- catch {
4339
- // Best-effort: word + entity trapdoors alone still surface the claim.
4340
- }
4341
- const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
4342
- return {
4343
- blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
4344
- encryptedEmbedding,
4345
- };
4346
- },
4347
- };
4348
- };
4349
- // ---------------------------------------------------------------
4350
- // Tool: totalreclaw_retype (3.3.1-rc.2 — agent-facing taxonomy edit)
4351
- // ---------------------------------------------------------------
4352
- api.registerTool({
4353
- name: 'totalreclaw_retype',
4354
- label: 'Retype',
4355
- description: 'Reclassify an existing memory from one taxonomy type to another (claim / preference / ' +
4356
- 'directive / commitment / episode / summary). Use when the user corrects a memory\'s ' +
4357
- 'category — e.g. "that\'s actually a preference, not a fact" or "file this as a ' +
4358
- 'commitment, not a claim". Writes a new v1.1 blob with the updated type and tombstones ' +
4359
- 'the old fact on-chain.\n\n' +
4360
- 'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
4361
- 'find the fact_id, then pass it here with the new type.',
4362
- parameters: {
4363
- type: 'object',
4364
- properties: {
4365
- fact_id: {
4366
- type: 'string',
4367
- description: 'The UUID of the memory to reclassify. Get this from a prior ' +
4368
- '`totalreclaw_recall` result.',
4369
- },
4370
- new_type: {
4371
- type: 'string',
4372
- enum: ['claim', 'preference', 'directive', 'commitment', 'episode', 'summary'],
4373
- description: 'The new taxonomy type. claim=factual statement, preference=opinion/like/dislike, ' +
4374
- 'directive=instruction, commitment=promise/plan, episode=event, summary=aggregate.',
4375
- },
4376
- },
4377
- required: ['fact_id', 'new_type'],
4378
- additionalProperties: false,
4379
- },
4380
- async execute(_toolCallId, params) {
4381
- try {
4382
- await requireFullSetup(api.logger);
4383
- if (!isSubgraphMode()) {
4384
- return {
4385
- content: [{
4386
- type: 'text',
4387
- text: 'Retype is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4388
- }],
4389
- };
4390
- }
4391
- const validation = validateRetypeArgs(params);
4392
- if (!validation.ok) {
4393
- return { content: [{ type: 'text', text: validation.error }] };
4394
- }
4395
- const deps = buildRetypeSetScopeDeps();
4396
- const result = await executeRetype(validation.factId, validation.newType, deps);
4397
- if (result.success) {
4398
- 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)})`);
4399
- return {
4400
- content: [{
4401
- type: 'text',
4402
- text: `Retyped memory ${result.fact_id} from ${result.previous_type} to ${result.new_type}. ` +
4403
- `New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4404
- }],
4405
- details: result,
4406
- };
4407
- }
4408
- api.logger.error(`totalreclaw_retype failed: ${result.error}`);
4409
- return {
4410
- content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(result.error ?? 'unknown error')}` }],
4411
- details: result,
4412
- };
4413
- }
4414
- catch (err) {
4415
- const message = err instanceof Error ? err.message : String(err);
4416
- api.logger.error(`totalreclaw_retype failed: ${message}`);
4417
- return {
4418
- content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(message)}` }],
4419
- };
4420
- }
4421
- },
4422
- }, { name: 'totalreclaw_retype' });
4423
- // ---------------------------------------------------------------
4424
- // Tool: totalreclaw_set_scope (3.3.1-rc.2 — agent-facing scope edit)
4425
- // ---------------------------------------------------------------
4426
- api.registerTool({
4427
- name: 'totalreclaw_set_scope',
4428
- label: 'Set Scope',
4429
- description: 'Move an existing memory to a different scope (work / personal / health / family / ' +
4430
- 'creative / finance / misc / unspecified). Use when the user re-categorizes a memory\'s ' +
4431
- 'domain — e.g. "put that under work", "this is a health thing", "move this to personal". ' +
4432
- 'Writes a new v1.1 blob with the updated scope and tombstones the old fact on-chain.\n\n' +
4433
- 'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
4434
- 'find the fact_id, then pass it here with the new scope.',
4435
- parameters: {
4436
- type: 'object',
4437
- properties: {
4438
- fact_id: {
4439
- type: 'string',
4440
- description: 'The UUID of the memory to rescope. Get this from a prior `totalreclaw_recall` result.',
4441
- },
4442
- new_scope: {
4443
- type: 'string',
4444
- enum: ['work', 'personal', 'health', 'family', 'creative', 'finance', 'misc', 'unspecified'],
4445
- description: 'The new scope. Used for filtered recall — e.g. "recall work-related memories only".',
4446
- },
4447
- },
4448
- required: ['fact_id', 'new_scope'],
4449
- additionalProperties: false,
4450
- },
4451
- async execute(_toolCallId, params) {
4452
- try {
4453
- await requireFullSetup(api.logger);
4454
- if (!isSubgraphMode()) {
4455
- return {
4456
- content: [{
4457
- type: 'text',
4458
- text: 'Set-scope is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
4459
- }],
4460
- };
4461
- }
4462
- const validation = validateSetScopeArgs(params);
4463
- if (!validation.ok) {
4464
- return { content: [{ type: 'text', text: validation.error }] };
4465
- }
4466
- const deps = buildRetypeSetScopeDeps();
4467
- const result = await executeSetScope(validation.factId, validation.newScope, deps);
4468
- if (result.success) {
4469
- 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)})`);
4470
- return {
4471
- content: [{
4472
- type: 'text',
4473
- text: `Moved memory ${result.fact_id} from scope "${result.previous_scope ?? 'unspecified'}" to "${result.new_scope}". ` +
4474
- `New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
4475
- }],
4476
- details: result,
4477
- };
4478
- }
4479
- api.logger.error(`totalreclaw_set_scope failed: ${result.error}`);
4480
- return {
4481
- content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(result.error ?? 'unknown error')}` }],
4482
- details: result,
4483
- };
4484
- }
4485
- catch (err) {
4486
- const message = err instanceof Error ? err.message : String(err);
4487
- api.logger.error(`totalreclaw_set_scope failed: ${message}`);
4488
- return {
4489
- content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(message)}` }],
4490
- };
4491
- }
4492
- },
4493
- }, { name: 'totalreclaw_set_scope' });
4494
- // ---------------------------------------------------------------
4495
- // Tool: totalreclaw_import_from
4496
- // ---------------------------------------------------------------
4497
- api.registerTool({
4498
- name: 'totalreclaw_import_from',
4499
- label: 'Import From',
4500
- description: 'Import memories from other AI memory tools (Mem0, MCP Memory Server, ChatGPT, Claude, Gemini, MemoClaw, or generic JSON/CSV). ' +
4501
- 'Provide the source name and either an API key, file content, or file path. ' +
4502
- 'Use dry_run=true to preview before importing. Idempotent — safe to run multiple times.',
4503
- parameters: {
4504
- type: 'object',
4505
- properties: {
4506
- source: {
4507
- type: 'string',
4508
- enum: ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini', 'memoclaw', 'generic-json', 'generic-csv'],
4509
- description: 'The source system to import from (gemini: Google Takeout HTML; chatgpt: conversations.json or memory text; claude: memory text)',
4510
- },
4511
- api_key: {
4512
- type: 'string',
4513
- description: 'API key for the source system (used once, never stored)',
4514
- },
4515
- source_user_id: {
4516
- type: 'string',
4517
- description: 'User or agent ID in the source system',
4518
- },
4519
- content: {
4520
- type: 'string',
4521
- description: 'File content (JSON, JSONL, or CSV)',
4522
- },
4523
- file_path: {
4524
- type: 'string',
4525
- description: 'Path to the file on disk',
4526
- },
4527
- namespace: {
4528
- type: 'string',
4529
- description: 'Target namespace (default: "imported")',
4530
- },
4531
- dry_run: {
4532
- type: 'boolean',
4533
- description: 'Preview without importing',
4534
- },
4535
- },
4536
- required: ['source'],
4537
- },
4538
- async execute(_toolCallId, params) {
4539
- try {
4540
- await requireFullSetup(api.logger);
4541
- return handlePluginImportFrom(params, api.logger);
4542
- }
4543
- catch (err) {
4544
- const message = err instanceof Error ? err.message : String(err);
4545
- return { error: message };
4546
- }
4547
- },
4548
- }, { name: 'totalreclaw_import_from' });
4549
- // ---------------------------------------------------------------
4550
- // Tool: totalreclaw_import_batch
4551
- // ---------------------------------------------------------------
4552
- api.registerTool({
4553
- name: 'totalreclaw_import_batch',
4554
- label: 'Import Batch',
4555
- description: 'Process one batch of a large import. Call repeatedly with increasing offset until is_complete=true.',
4556
- parameters: {
4557
- type: 'object',
4558
- properties: {
4559
- source: {
4560
- type: 'string',
4561
- enum: ['gemini', 'chatgpt', 'claude'],
4562
- description: 'Source format',
4563
- },
4564
- file_path: {
4565
- type: 'string',
4566
- description: 'Path to source file',
4567
- },
4568
- content: {
4569
- type: 'string',
4570
- description: 'File content (text sources)',
4571
- },
4572
- offset: {
4573
- type: 'number',
4574
- description: 'Starting chunk index (0-based)',
4575
- },
4576
- batch_size: {
4577
- type: 'number',
4578
- description: 'Chunks per call (default 25)',
4579
- },
4580
- },
4581
- required: ['source'],
4582
- },
4583
- async execute(_toolCallId, params) {
4584
- try {
4585
- await requireFullSetup(api.logger);
4586
- return handleBatchImport(params, api.logger);
4587
- }
4588
- catch (err) {
4589
- const message = err instanceof Error ? err.message : String(err);
4590
- return { error: message };
4591
- }
4592
- },
4593
- }, { name: 'totalreclaw_import_batch' });
4594
- // ---------------------------------------------------------------
4595
- // Tool: totalreclaw_upgrade
4596
- // ---------------------------------------------------------------
4597
- api.registerTool({
4598
- name: 'totalreclaw_upgrade',
4599
- label: 'Upgrade to Pro',
4600
- description: 'Upgrade to TotalReclaw Pro for unlimited encrypted memories. ' +
4601
- 'Returns a Stripe checkout URL for the user to complete payment via credit/debit card.',
4602
- parameters: {
4603
- type: 'object',
4604
- properties: {},
4605
- additionalProperties: false,
4606
- },
4607
- async execute() {
4608
- try {
4609
- await requireFullSetup(api.logger);
4610
- if (!authKeyHex) {
4611
- return {
4612
- content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
4613
- };
4614
- }
4615
- const serverUrl = CONFIG.serverUrl;
4616
- const walletAddr = subgraphOwner || userId || '';
4617
- if (!walletAddr) {
4618
- return {
4619
- content: [{ type: 'text', text: 'Wallet address not available. Please ensure the plugin is fully initialized.' }],
4620
- };
4621
- }
4622
- const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
4623
- method: 'POST',
4624
- headers: buildRelayHeaders({
4625
- 'Authorization': `Bearer ${authKeyHex}`,
4626
- 'Content-Type': 'application/json',
4627
- }),
4628
- body: JSON.stringify({
4629
- wallet_address: walletAddr,
4630
- tier: 'pro',
4631
- }),
4632
- });
4633
- if (!response.ok) {
4634
- const body = await response.text().catch(() => '');
4635
- return {
4636
- content: [{ type: 'text', text: `Failed to create checkout session (HTTP ${response.status}): ${body || response.statusText}` }],
4637
- };
4638
- }
4639
- const data = await response.json();
4640
- if (!data.checkout_url) {
4641
- return {
4642
- content: [{ type: 'text', text: 'Failed to create checkout session: no checkout URL returned.' }],
4643
- };
4644
- }
4645
- return {
4646
- content: [{ type: 'text', text: `Open this URL to upgrade to Pro: ${data.checkout_url}` }],
4647
- details: { checkout_url: data.checkout_url },
4648
- };
4649
- }
4650
- catch (err) {
4651
- const message = err instanceof Error ? err.message : String(err);
4652
- api.logger.error(`totalreclaw_upgrade failed: ${message}`);
4653
- return {
4654
- content: [{ type: 'text', text: `Failed to create checkout session: ${humanizeError(message)}` }],
4655
- };
4656
- }
4657
- },
4658
- }, { name: 'totalreclaw_upgrade' });
4659
- // ---------------------------------------------------------------
4660
- // Tool: totalreclaw_migrate
4661
- // ---------------------------------------------------------------
4662
- api.registerTool({
4663
- name: 'totalreclaw_migrate',
4664
- label: 'Migrate Testnet to Mainnet',
4665
- description: 'Migrate memories from testnet (Base Sepolia) to mainnet (Gnosis) after upgrading to Pro. ' +
4666
- 'Dry-run by default — set confirm=true to execute. Idempotent: re-running skips already-migrated facts.',
4667
- parameters: {
4668
- type: 'object',
4669
- properties: {
4670
- confirm: {
4671
- type: 'boolean',
4672
- description: 'Set to true to execute the migration. Without it, returns a dry-run preview.',
4673
- default: false,
4674
- },
4675
- },
4676
- additionalProperties: false,
4677
- },
4678
- async execute(_params) {
4679
- try {
4680
- await requireFullSetup(api.logger);
4681
- if (!authKeyHex || !subgraphOwner) {
4682
- return {
4683
- content: [{ type: 'text', text: 'Plugin not fully initialized. Ensure TOTALRECLAW_RECOVERY_PHRASE is set.' }],
4684
- };
4685
- }
4686
- if (!isSubgraphMode()) {
4687
- return {
4688
- content: [{ type: 'text', text: 'Migration is only available with the managed service (subgraph mode).' }],
4689
- };
4690
- }
4691
- const confirm = _params?.confirm === true;
4692
- const serverUrl = CONFIG.serverUrl;
4693
- // 1. Check billing tier
4694
- const billingResp = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`, {
4695
- method: 'GET',
4696
- headers: buildRelayHeaders({
4697
- 'Authorization': `Bearer ${authKeyHex}`,
4698
- 'Content-Type': 'application/json',
4699
- }),
4700
- });
4701
- if (!billingResp.ok) {
4702
- return { content: [{ type: 'text', text: `Failed to check billing tier (HTTP ${billingResp.status}).` }] };
4703
- }
4704
- const billingData = await billingResp.json();
4705
- if (billingData.tier !== 'pro') {
4706
- return {
4707
- content: [{ type: 'text', text: 'Migration requires Pro tier. Use totalreclaw_upgrade to upgrade first.' }],
4708
- };
4709
- }
4710
- // 2. Fetch testnet facts via relay (chain=testnet query param)
4711
- const testnetSubgraphUrl = `${serverUrl}/v1/subgraph?chain=testnet`;
4712
- const mainnetSubgraphUrl = `${serverUrl}/v1/subgraph`;
4713
- api.logger.info('Fetching testnet facts...');
4714
- const testnetFacts = await fetchAllFactsByOwner(testnetSubgraphUrl, subgraphOwner, authKeyHex);
4715
- if (testnetFacts.length === 0) {
4716
- return {
4717
- content: [{ type: 'text', text: 'No facts found on testnet. Nothing to migrate.' }],
4718
- };
4719
- }
4720
- // 3. Check mainnet for existing facts (idempotency)
4721
- api.logger.info('Checking mainnet for existing facts...');
4722
- const mainnetFps = await fetchContentFingerprintsByOwner(mainnetSubgraphUrl, subgraphOwner, authKeyHex);
4723
- const factsToMigrate = testnetFacts.filter(f => !f.contentFp || !mainnetFps.has(f.contentFp));
4724
- const alreadyOnMainnet = testnetFacts.length - factsToMigrate.length;
4725
- // 4. Dry-run
4726
- if (!confirm) {
4727
- const msg = factsToMigrate.length === 0
4728
- ? `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.`
4729
- : `Found ${factsToMigrate.length} facts to migrate from testnet to Gnosis mainnet (${alreadyOnMainnet} already on mainnet). Call with confirm=true to proceed.`;
4730
- return {
4731
- content: [{ type: 'text', text: msg }],
4732
- details: {
4733
- mode: 'dry_run',
4734
- testnet_facts: testnetFacts.length,
4735
- already_on_mainnet: alreadyOnMainnet,
4736
- to_migrate: factsToMigrate.length,
4737
- },
4738
- };
4739
- }
4740
- // 5. Execute migration
4741
- if (factsToMigrate.length === 0) {
4742
- return {
4743
- content: [{ type: 'text', text: `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.` }],
4744
- };
4745
- }
4746
- // Fetch blind indices
4747
- api.logger.info(`Fetching blind indices for ${factsToMigrate.length} facts...`);
4748
- const factIds = factsToMigrate.map(f => f.id);
4749
- const blindIndicesMap = await fetchBlindIndicesByFactIds(testnetSubgraphUrl, factIds, authKeyHex);
4750
- // Build protobuf payloads
4751
- const payloads = [];
4752
- for (const fact of factsToMigrate) {
4753
- const blobHex = fact.encryptedBlob.startsWith('0x') ? fact.encryptedBlob.slice(2) : fact.encryptedBlob;
4754
- const indices = blindIndicesMap.get(fact.id) || [];
4755
- const factPayload = {
4756
- id: fact.id,
4757
- timestamp: new Date().toISOString(),
4758
- owner: subgraphOwner,
4759
- encryptedBlob: blobHex,
4760
- blindIndices: indices,
4761
- decayScore: parseFloat(fact.decayScore) || 0.5,
4762
- source: fact.source || 'migration',
4763
- contentFp: fact.contentFp || '',
4764
- agentId: fact.agentId || 'openclaw-plugin',
4765
- encryptedEmbedding: fact.encryptedEmbedding || undefined,
4766
- version: PROTOBUF_VERSION_V4,
4767
- };
4768
- payloads.push(encodeFactProtobuf(factPayload));
4769
- }
4770
- // Batch submit (15 per UserOp)
4771
- const BATCH_SIZE = 15;
4772
- const batchConfig = { ...getSubgraphConfig(), authKeyHex: authKeyHex, walletAddress: subgraphOwner ?? undefined };
4773
- let migrated = 0;
4774
- let failedBatches = 0;
4775
- for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
4776
- const batch = payloads.slice(i, i + BATCH_SIZE);
4777
- const batchNum = Math.floor(i / BATCH_SIZE) + 1;
4778
- const totalBatches = Math.ceil(payloads.length / BATCH_SIZE);
4779
- api.logger.info(`Migrating batch ${batchNum}/${totalBatches} (${batch.length} facts)...`);
4780
- try {
4781
- const result = await submitFactBatchOnChain(batch, batchConfig);
4782
- if (result.success) {
4783
- migrated += batch.length;
4784
- }
4785
- else {
4786
- failedBatches++;
4787
- }
4788
- }
4789
- catch (err) {
4790
- const msg = err instanceof Error ? err.message : String(err);
4791
- api.logger.error(`Migration batch ${batchNum} failed: ${msg}`);
4792
- failedBatches++;
4793
- }
4794
- }
4795
- const resultMsg = failedBatches === 0
4796
- ? `Successfully migrated ${migrated} memories from testnet to Gnosis mainnet.`
4797
- : `Migrated ${migrated}/${factsToMigrate.length} memories. ${failedBatches} batch(es) failed — re-run to retry (idempotent).`;
4798
- return {
4799
- content: [{ type: 'text', text: resultMsg }],
4800
- details: {
4801
- mode: 'executed',
4802
- testnet_facts: testnetFacts.length,
4803
- already_on_mainnet: alreadyOnMainnet,
4804
- to_migrate: factsToMigrate.length,
4805
- migrated,
4806
- failed_batches: failedBatches,
4807
- },
4808
- };
4809
- }
4810
- catch (err) {
4811
- const message = err instanceof Error ? err.message : String(err);
4812
- api.logger.error(`totalreclaw_migrate failed: ${message}`);
4813
- return {
4814
- content: [{ type: 'text', text: `Migration failed: ${humanizeError(message)}` }],
4815
- };
4816
- }
4817
- },
4818
- }, { name: 'totalreclaw_migrate' });
4819
- // ---------------------------------------------------------------
4820
- // Tools: totalreclaw_setup + totalreclaw_onboarding_start —
4821
- // REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
4822
- // ---------------------------------------------------------------
4823
- //
4824
- // rc.4 left these two registrations in place as *neutered* stubs —
4825
- // ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
4826
- // and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
4827
- // was already pointer-only. Neither path could leak a phrase in
4828
- // rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
4829
- // regression surface: any future patch that re-enables phrase
4830
- // acceptance (e.g. a flag-driven "power-user" path) would silently
4831
- // re-open the leak, and their mere presence in the tool registry
4832
- // keeps signalling to agents that "phrase handling happens here".
4833
- //
4834
- // Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
4835
- // facilitated setup surface is ``totalreclaw_pair`` (browser-side
4836
- // crypto keeps the phrase out of the LLM round-trip by construction).
4837
- // rc.5 deletes both registrations outright. The underlying CLI
4838
- // wizard (``openclaw totalreclaw onboard``) is unchanged — users
4839
- // run it in their own terminal, outside any agent shell.
4840
- //
4841
- // Audit assertion: ``phrase-safety-registry.test.ts`` asserts
4842
- // neither name is present in the ``api.registerTool`` call list.
4843
- // Re-adding either fails CI.
4844
3873
  //
4845
- // Historical tombstone (so LLM-assisted contributors don't re-add
4846
- // the former shape from training-data memory): rc.4 registered two
4847
- // tools by the names "totalreclaw_setup" and
4848
- // "totalreclaw_onboarding_start" as pointer-only stubs. Both were
4849
- // deleted in rc.5. Do not re-introduce.
4850
- // ---------------------------------------------------------------
4851
- // Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
4852
- //
4853
- // rc.3 shipped a `totalreclaw_onboard` agent tool that generated a
4854
- // fresh BIP-39 mnemonic in-process, wrote it to credentials.json,
4855
- // and returned `{scope_address, credentials_path}` to the agent.
4856
- // `emitPhrase: false` kept the mnemonic OUT of the tool's return
4857
- // payload, but NOTHING ARCHITECTURALLY PREVENTED leakage — a future
4858
- // patch could regress the flag, a different code path could echo
4859
- // the mnemonic in a log/error message the agent captures, or the
4860
- // mere existence of the tool implied to agents that "generating a
4861
- // phrase here is fine" (it isn't).
4862
- //
4863
- // Per ``project_phrase_safety_rule.md``
4864
- // (memory file in p-diogo/totalreclaw-internal — absolute rule:
4865
- // "recovery phrase MUST NEVER cross the LLM context in ANY form"),
4866
- // phrase-generating agent tools are forbidden. The ONLY approved
4867
- // agent-facilitated setup surface is ``totalreclaw_pair`` (browser-
4868
- // side crypto keeps the phrase out of the LLM round-trip by
4869
- // construction). The underlying ``runNonInteractiveOnboard`` code
4870
- // path is still reachable via the CLI ``openclaw totalreclaw onboard``
4871
- // — that path runs in the user's own terminal, OUTSIDE any agent
4872
- // shell, so phrase stdout never feeds back into LLM context.
4873
- //
4874
- // Audit assertion: ``tool-gating.test.ts`` enforces the removal —
4875
- // any future re-registration of ``totalreclaw_onboard`` (or any
4876
- // phrase-generating variant like ``totalreclaw_onboard_generate``,
4877
- // ``totalreclaw_restore_phrase``) fails CI.
4878
- // ---------------------------------------------------------------
4879
- // ---------------------------------------------------------------
4880
- // Tool: totalreclaw_pair (3.3.1-rc.2 — agent-callable pair-generate)
4881
- //
4882
- // Creates a pairing session (browser-mediated recovery-phrase sync),
4883
- // returns the URL + PIN + QR ASCII to the agent. The agent relays
4884
- // these to the user (paste-URL or scan-QR flow). The phrase itself
4885
- // NEVER crosses the gateway — the pair-http endpoint does the E2EE
4886
- // handshake with the browser pair-page.
4887
- // ---------------------------------------------------------------
4888
- api.registerTool({
4889
- name: 'totalreclaw_pair',
4890
- label: 'Set up the user\'s TotalReclaw account (encrypted, browser-side)',
4891
- description: 'Set up the user\'s TotalReclaw account (encrypted, browser-side recovery-phrase ' +
4892
- 'generation or import). Starts a brief remote session so the user can create or ' +
4893
- 'import a TotalReclaw recovery phrase from their phone or another browser. ' +
4894
- 'Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent ' +
4895
- 'relays to the user. The recovery phrase itself is generated/entered in the ' +
4896
- 'BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches ' +
4897
- 'the LLM provider or the chat transcript.\n\n' +
4898
- 'This is the CANONICAL agent-facilitated account-setup surface — use it whenever ' +
4899
- 'the user asks you to set up TotalReclaw, regardless of whether they have ' +
4900
- 'terminal access. Narrate it to the user as "setting up your TotalReclaw ' +
4901
- 'account". Browser-side crypto keeps the recovery phrase out of the LLM context ' +
4902
- 'entirely. If a user explicitly prefers local-terminal account setup with no ' +
4903
- 'browser, point them at `totalreclaw_onboarding_start` (a pointer to the CLI ' +
4904
- 'wizard they run on their own terminal, NOT through your shell tool).\n\n' +
4905
- 'Tool name `totalreclaw_pair` is kept for backward compatibility — function-wise ' +
4906
- 'this is the account-setup tool.',
4907
- parameters: {
4908
- type: 'object',
4909
- properties: {
4910
- mode: {
4911
- type: 'string',
4912
- enum: ['generate', 'import'],
4913
- description: '"generate" = the browser will create a NEW recovery phrase. "import" = the ' +
4914
- 'browser will accept an EXISTING phrase that the user pastes in their browser ' +
4915
- '(never through chat).',
4916
- default: 'generate',
4917
- },
4918
- },
4919
- additionalProperties: false,
4920
- },
4921
- async execute(_toolCallId, params) {
4922
- const rawMode = params?.mode;
4923
- const mode = rawMode === 'import' ? 'import' : 'generate';
4924
- const pairMode = CONFIG.pairMode;
4925
- try {
4926
- // 3.3.1-rc.11 — relay-brokered pair by default (universal reachability).
4927
- // `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
4928
- // for air-gapped / self-hosted setups. Both paths return the same
4929
- // tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
4930
- // only the URL origin differs.
4931
- let url;
4932
- let pin;
4933
- let sidOrToken;
4934
- let expiresAtMs;
4935
- let localSession;
4936
- if (pairMode === 'relay') {
4937
- const { openRemotePairSession, awaitPhraseUpload } = await import('./pair-remote-client.js');
4938
- const remoteSession = await openRemotePairSession({
4939
- relayBaseUrl: CONFIG.pairRelayUrl,
4940
- mode: mode === 'generate' ? 'generate' : 'import',
4941
- });
4942
- url = remoteSession.url;
4943
- pin = remoteSession.pin;
4944
- sidOrToken = remoteSession.token;
4945
- // Relay sends ISO-8601; convert to ms for tool payload parity.
4946
- const parsed = Date.parse(remoteSession.expiresAt);
4947
- expiresAtMs = Number.isFinite(parsed)
4948
- ? parsed
4949
- : Date.now() + 5 * 60_000;
4950
- // Background task — writes credentials.json + flips state when
4951
- // the browser completes the flow. Tool handler returns
4952
- // immediately so the agent can tell the user the URL + PIN.
4953
- //
4954
- // 3.3.4-rc.2 (Pedro QA — pair flow stuck-session) — wrap the
4955
- // WS-await in a 60s hard timeout. The relay drops sessions on
4956
- // `ws_close` (separately patched relay-side); without this
4957
- // bound the background task hangs in `waitNextMessage` for the
4958
- // full 5-minute session TTL before resolving, and downstream
4959
- // tooling that polls the gateway for completion sees stale
4960
- // `processing` state at 129s/159s/189s. 60s is the user-side
4961
- // deadline we surface in chat: long enough for a slow scan-
4962
- // and-paste, short enough that a fresh URL request is the
4963
- // obvious next step. Structured `timed_out` error in the log
4964
- // lets ops grep for the failure mode independently of generic
4965
- // ws-close errors.
4966
- const PAIR_TOOL_HARD_TIMEOUT_MS = 60_000;
4967
- void (async () => {
4968
- try {
4969
- const phraseUploadPromise = awaitPhraseUpload(remoteSession, {
4970
- phraseValidator: (p) => validateMnemonic(p, wordlist),
4971
- completePairing: async ({ mnemonic }) => {
4972
- try {
4973
- // 3.3.1 (internal#130) — derive + persist the
4974
- // Smart Account address now so the user can see
4975
- // it immediately after pair, before any chain
4976
- // write. Mnemonic stays in this scope (already
4977
- // on disk in credentials.json); only the
4978
- // derived public scope_address is added.
4979
- let scopeAddress;
4980
- try {
4981
- scopeAddress = await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
4982
- }
4983
- catch (deriveErr) {
4984
- api.logger.warn(`totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
4985
- }
4986
- const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
4987
- const next = { ...creds, mnemonic };
4988
- if (scopeAddress)
4989
- next.scope_address = scopeAddress;
4990
- if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
4991
- return { state: 'error', error: 'credentials_write_failed' };
4992
- }
4993
- setRecoveryPhraseOverride(mnemonic);
4994
- writeOnboardingState(CONFIG.onboardingStatePath, {
4995
- onboardingState: 'active',
4996
- createdBy: mode === 'generate' ? 'generate' : 'import',
4997
- credentialsCreatedAt: new Date().toISOString(),
4998
- version: pluginVersion ?? '3.3.0',
4999
- });
5000
- api.logger.info(`totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`);
5001
- return { state: 'active' };
5002
- }
5003
- catch (err) {
5004
- const msg = err instanceof Error ? err.message : String(err);
5005
- api.logger.error(`totalreclaw_pair(relay): completePairing failed: ${msg}`);
5006
- return { state: 'error', error: msg };
5007
- }
5008
- },
5009
- // 3.3.4-rc.2 — also pass through to awaitPhraseUpload so its
5010
- // internal `waitNextMessage` timer matches the outer race.
5011
- timeoutMs: PAIR_TOOL_HARD_TIMEOUT_MS,
5012
- });
5013
- // 3.3.4-rc.2 — outer Promise.race guard. Resolves to a
5014
- // sentinel ({ status: 'timed_out', ... }) so the catch
5015
- // handler can distinguish a hard-timeout from a generic
5016
- // ws-close error and surface it explicitly.
5017
- const TIMEOUT_SENTINEL = {
5018
- status: 'timed_out',
5019
- message: `Pair flow timed out (${PAIR_TOOL_HARD_TIMEOUT_MS / 1000}s) — generate a new URL with totalreclaw_pair.`,
5020
- };
5021
- let hardTimer;
5022
- const hardTimeoutPromise = new Promise((resolve) => {
5023
- hardTimer = setTimeout(() => resolve(TIMEOUT_SENTINEL), PAIR_TOOL_HARD_TIMEOUT_MS);
5024
- });
5025
- try {
5026
- const raced = await Promise.race([
5027
- phraseUploadPromise,
5028
- hardTimeoutPromise,
5029
- ]);
5030
- if (raced &&
5031
- typeof raced === 'object' &&
5032
- raced.status === 'timed_out') {
5033
- api.logger.warn(`totalreclaw_pair(relay): hard timeout — ${raced.message} (token=${remoteSession.token.slice(0, 8)}…)`);
5034
- }
5035
- }
5036
- finally {
5037
- if (hardTimer)
5038
- clearTimeout(hardTimer);
5039
- }
5040
- }
5041
- catch (bgErr) {
5042
- // Expected on TTL expiry / user-aborts — log at warn, not error.
5043
- const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
5044
- api.logger.warn(`totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`);
5045
- }
5046
- })();
5047
- }
5048
- else {
5049
- // Local loopback path (rc.10 behaviour).
5050
- const { createPairSession } = await import('./pair-session-store.js');
5051
- const { generateGatewayKeypair } = await import('./pair-crypto.js');
5052
- const kp = generateGatewayKeypair();
5053
- const session = await createPairSession(CONFIG.pairSessionsPath, {
5054
- mode,
5055
- operatorContext: { channel: 'agent' },
5056
- rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5057
- rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5058
- });
5059
- url = buildPairingUrl(api, session);
5060
- pin = session.secondaryCode;
5061
- sidOrToken = session.sid;
5062
- expiresAtMs = session.expiresAtMs;
5063
- localSession = session;
5064
- }
5065
- // QR renderers — same for both modes; input is the URL string.
5066
- const { defaultRenderQr } = await import('./pair-cli.js');
5067
- const qrAscii = await new Promise((resolve) => {
5068
- let settled = false;
5069
- const t = setTimeout(() => {
5070
- if (!settled) {
5071
- settled = true;
5072
- resolve('');
5073
- }
5074
- }, 5_000);
5075
- try {
5076
- defaultRenderQr(url, (ascii) => {
5077
- if (settled)
5078
- return;
5079
- settled = true;
5080
- clearTimeout(t);
5081
- resolve(ascii);
5082
- });
5083
- }
5084
- catch {
5085
- if (settled)
5086
- return;
5087
- settled = true;
5088
- clearTimeout(t);
5089
- resolve('');
5090
- }
5091
- });
5092
- // 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
5093
- let qrPngB64 = '';
5094
- let qrUnicode = '';
5095
- try {
5096
- const { encodePng, encodeUnicode } = await import('./pair-qr.js');
5097
- const [pngBuf, uni] = await Promise.all([
5098
- encodePng(url),
5099
- encodeUnicode(url),
5100
- ]);
5101
- qrPngB64 = pngBuf.toString('base64');
5102
- qrUnicode = uni;
5103
- }
5104
- catch (qrErr) {
5105
- api.logger.warn(`totalreclaw_pair: QR encode failed (non-fatal): ${qrErr instanceof Error ? qrErr.message : String(qrErr)}`);
5106
- }
5107
- api.logger.info(`totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`);
5108
- // Voidly reference localSession so TS does not flag the unused
5109
- // local-branch binding. Future rc.12 diagnostics can expose
5110
- // `session.mode` / `session.status` separately.
5111
- void localSession;
5112
- return {
5113
- content: [{
5114
- type: 'text',
5115
- text: `Pairing session started.\n\n` +
5116
- `URL: ${url}\n\n` +
5117
- `PIN (type this into the browser): ${pin}\n\n` +
5118
- (qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
5119
- `Instructions for the user:\n` +
5120
- `1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
5121
- `2. ` +
5122
- (mode === 'generate'
5123
- ? `The browser will generate a NEW 12-word recovery phrase and ask the user to write it down + retype 3 words before finalizing.`
5124
- : `The browser will accept an EXISTING phrase that the user pastes in the browser (never through chat).`) +
5125
- `\n3. Enter the 6-digit PIN shown above into the browser.\n` +
5126
- `4. The encrypted phrase uploads to this gateway — it NEVER touches the LLM.\n` +
5127
- `5. Come back to chat once the browser says "Pairing complete".\n\n` +
5128
- `This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
5129
- }],
5130
- details: {
5131
- sid: sidOrToken,
5132
- url,
5133
- pin,
5134
- mode,
5135
- expires_at_ms: expiresAtMs,
5136
- qr_ascii: qrAscii,
5137
- qr_png_b64: qrPngB64,
5138
- qr_unicode: qrUnicode,
5139
- // rc.11 — surface the transport so downstream tooling (QA
5140
- // harness asserters, telemetry) can confirm which path
5141
- // served the URL. Either 'relay' or 'local'.
5142
- transport: pairMode,
5143
- },
5144
- };
5145
- }
5146
- catch (err) {
5147
- const message = err instanceof Error ? err.message : String(err);
5148
- api.logger.error(`totalreclaw_pair failed: ${message}`);
5149
- return {
5150
- content: [{ type: 'text', text: `Failed to start pairing session: ${humanizeError(message)}` }],
5151
- details: { error: message },
5152
- };
5153
- }
5154
- },
5155
- }, { name: 'totalreclaw_pair' });
5156
- // 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
5157
- // ops/QA can grep gateway logs for definitive proof the tool was
5158
- // declared. If the agent then reports the tool is missing from its
5159
- // tool list, the gap is upstream OpenClaw tool propagation, not our
5160
- // plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
5161
- //
5162
- // 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
5163
- // bleeding into `openclaw agent --json` stdout, breaking programmatic
5164
- // parsers that expect the JSON-RPC body to be the only thing on the
5165
- // wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
5166
- // `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
5167
- // a tool-injection regression. Default OFF — clean stdout for users.
5168
- if (CONFIG.verboseRegister) {
5169
- api.logger.info('TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
5170
- 'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
5171
- '`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.');
5172
- }
5173
- // ---------------------------------------------------------------
5174
- // Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
5175
- //
5176
- // Lets the agent file a structured QA-bug issue to
5177
- // `p-diogo/totalreclaw-internal` during RC testing. Only registered
5178
- // when the plugin version contains `-rc.` — stable users never see it.
5179
- //
5180
- // Secrets (recovery phrases, API keys, Telegram bot tokens) are
5181
- // redacted inside `postQaBugIssue` before the POST. The agent should
5182
- // still avoid passing raw secrets — see SKILL.md addendum.
5183
- // ---------------------------------------------------------------
5184
- if (rcMode) {
5185
- api.registerTool({
5186
- name: 'totalreclaw_report_qa_bug',
5187
- label: 'File a QA bug issue (RC builds only)',
5188
- description: 'File a structured QA bug report to the internal tracker. RC-only; never available in stable builds. ' +
5189
- 'Do NOT call auto-file — ask the user first before invoking. The tool redacts recovery phrases, API keys, ' +
5190
- 'and Telegram bot tokens from all free-text fields before posting, but the agent SHOULD still avoid ' +
5191
- 'passing raw secrets.',
5192
- parameters: {
5193
- type: 'object',
5194
- properties: {
5195
- integration: {
5196
- type: 'string',
5197
- enum: ['plugin', 'hermes', 'nanoclaw', 'mcp', 'relay', 'clawhub', 'docs', 'other'],
5198
- description: 'Which TotalReclaw surface is affected.',
5199
- },
5200
- rc_version: {
5201
- type: 'string',
5202
- description: 'Exact RC version string (e.g. "3.3.1-rc.3" or "2.3.1rc3").',
5203
- },
5204
- severity: {
5205
- type: 'string',
5206
- enum: ['blocker', 'high', 'medium', 'low'],
5207
- description: 'blocker=release blocked, high=major UX failure, medium=annoying, low=polish.',
5208
- },
5209
- title: {
5210
- type: 'string',
5211
- description: 'Short summary, <60 chars. Prefix "[qa-bug]" is added automatically.',
5212
- maxLength: 60,
5213
- },
5214
- symptom: {
5215
- type: 'string',
5216
- description: 'What happened (redacted automatically).',
5217
- },
5218
- expected: {
5219
- type: 'string',
5220
- description: 'What should have happened.',
5221
- },
5222
- repro: {
5223
- type: 'string',
5224
- description: 'Reproduction steps (redacted automatically).',
5225
- },
5226
- logs: {
5227
- type: 'string',
5228
- description: 'Log excerpts / error messages (redacted automatically).',
5229
- },
5230
- environment: {
5231
- type: 'string',
5232
- description: 'Host, Docker/native, OpenClaw version, LLM provider, etc.',
5233
- },
5234
- },
5235
- required: [
5236
- 'integration',
5237
- 'rc_version',
5238
- 'severity',
5239
- 'title',
5240
- 'symptom',
5241
- 'expected',
5242
- 'repro',
5243
- 'logs',
5244
- 'environment',
5245
- ],
5246
- additionalProperties: false,
5247
- },
5248
- async execute(_toolCallId, params) {
5249
- try {
5250
- const { postQaBugIssue } = await import('./qa-bug-report.js');
5251
- // The token is resolved via CONFIG (config.ts) so index.ts
5252
- // stays clean of env-harvesting triggers.
5253
- const token = CONFIG.qaGithubToken;
5254
- if (!token) {
5255
- return {
5256
- content: [{
5257
- type: 'text',
5258
- text: 'Cannot file QA bug: no GitHub token found. The operator must export ' +
5259
- 'TOTALRECLAW_QA_GITHUB_TOKEN (or GITHUB_TOKEN) with `repo` scope to enable ' +
5260
- 'agent-filed bug reports during RC testing.',
5261
- }],
5262
- details: { error: 'missing_github_token' },
5263
- };
5264
- }
5265
- // rc.14 — `repo` is resolved inside `postQaBugIssue` via
5266
- // `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
5267
- // refuses any slug that isn't a `-internal` fork. Pass the
5268
- // config-surfaced override so env reads stay in config.ts.
5269
- const repoOverride = CONFIG.qaRepoOverride || undefined;
5270
- const result = await postQaBugIssue(params, {
5271
- githubToken: token,
5272
- repo: repoOverride,
5273
- logger: api.logger,
5274
- });
5275
- return {
5276
- content: [{
5277
- type: 'text',
5278
- text: `Filed QA bug #${result.issue_number}: ${result.issue_url}`,
5279
- }],
5280
- details: { issue_url: result.issue_url, issue_number: result.issue_number },
5281
- };
5282
- }
5283
- catch (err) {
5284
- const message = err instanceof Error ? err.message : String(err);
5285
- api.logger.error(`totalreclaw_report_qa_bug failed: ${message}`);
5286
- return {
5287
- content: [{
5288
- type: 'text',
5289
- text: `Failed to file QA bug: ${message}`,
5290
- }],
5291
- details: { error: message },
5292
- };
5293
- }
5294
- },
5295
- }, { name: 'totalreclaw_report_qa_bug' });
5296
- // 3.3.1-rc.21 (issue #128): demote the registration-confirmation
5297
- // breadcrumb to verbose-only. Same `--json` stdout pollution risk
5298
- // as the totalreclaw_pair breadcrumb above; ops can opt back in
5299
- // via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
5300
- if (CONFIG.verboseRegister) {
5301
- api.logger.info('totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).');
5302
- }
5303
- }
5304
- // ---------------------------------------------------------------
5305
- // Hook: before_tool_call (3.2.0 memory-tool gate)
5306
- // ---------------------------------------------------------------
5307
- //
5308
- // Blocks every memory tool until onboarding state is `active`. The
5309
- // `blockReason` string is LLM-visible but carries no secret — it's a
5310
- // pointer to the CLI wizard.
3874
+ // Phase 3.3: gates the bundled NATIVE memory tools (memory_search,
3875
+ // memory_get) until onboarding state is `active`. The blockReason string
3876
+ // is LLM-visible but carries no secret — it's a pointer to the CLI pair
3877
+ // surface (`tr pair --url-pin`). Without this gate, an unpaired agent
3878
+ // would hit the adapter's fail-soft empty-result path and surface
3879
+ // "no memories found" with no actionable guidance.
5311
3880
  //
5312
- // Non-gated tools: totalreclaw_upgrade, totalreclaw_migrate,
5313
- // totalreclaw_onboarding_start, totalreclaw_setup (deprecated).
5314
- // Billing tools work pre-onboarding because they help the user reach a
5315
- // Pro tier before they have memories to store; setup-adjacent tools
5316
- // return their own routing messages.
3881
+ // Non-gated tools: every other tool the agent sees (read/write helpers,
3882
+ // the host's own tools, etc.). The pair surface is intentionally not
3883
+ // gated users must be able to start onboarding before the vault is
3884
+ // active.
5317
3885
  //
5318
3886
  // Decision logic lives in `tool-gating.ts` so it's unit-testable
5319
3887
  // without a full plugin host.
@@ -5464,18 +4032,18 @@ const plugin = {
5464
4032
  '## TotalReclaw setup pending\n\n' +
5465
4033
  'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
5466
4034
  'If the user asks about memory features or wants to configure TotalReclaw, ' +
5467
- 'call the `totalreclaw_pair` tool it returns a pairing URL + 6-digit PIN ' +
5468
- 'the user opens in a browser (phrase is generated and encrypted in-browser, ' +
5469
- 'never leaves their device in plaintext). If `totalreclaw_pair` is not in ' +
5470
- 'your tool list (container-based agents sometimes miss it), shell out to ' +
5471
- '`openclaw totalreclaw pair generate --url-pin-only` on the gateway host ' +
5472
- 'it emits a single JSON line `{v,url,pin,expires_at_ms}` with no phrase ' +
5473
- 'material; hand the user the `url` and `pin`.\n\n' +
4035
+ 'pairing is QR-only and CLI-driven (there is no agent tool for it). Run ' +
4036
+ '`tr pair --url-pin` in a shell on the gateway host it emits a single JSON ' +
4037
+ 'line `{v,url,pin,expires_at_ms}` with no phrase material. Hand the user the ' +
4038
+ '`url` and `pin`; they open the URL in a browser where the recovery phrase is ' +
4039
+ 'generated and encrypted in-browser and never leaves their device in plaintext. ' +
4040
+ 'Once pairing completes, memory_search/memory_get unlock automatically and this ' +
4041
+ 'banner stops appearing.\n\n' +
5474
4042
  '**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
5475
4043
  '**Do NOT** run `openclaw totalreclaw onboard` — that CLI emits the recovery ' +
5476
- 'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
5477
- '(tool or `--url-pin-only` CLI) instead; `onboard` is reserved for users ' +
5478
- 'running it directly in their own local terminal.',
4044
+ 'phrase on stdout and would leak it into the LLM transcript. Use `tr pair --url-pin` ' +
4045
+ '(or `openclaw totalreclaw pair generate --url-pin-only`) instead; `onboard` is ' +
4046
+ 'reserved for users running it directly in their own local terminal.',
5479
4047
  };
5480
4048
  }
5481
4049
  // One-time welcome message (first conversation after setup or returning user)
@@ -5491,7 +4059,7 @@ const plugin = {
5491
4059
  const tier = cache?.tier || 'free';
5492
4060
  const tierInfo = tier === 'pro'
5493
4061
  ? 'You are on the **Pro** tier — unlimited memories, permanently stored on Gnosis mainnet.'
5494
- : 'You are on the **Free** tier — memories stored on testnet. Use the totalreclaw_upgrade tool to upgrade to Pro for permanent on-chain storage.';
4062
+ : '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.)';
5495
4063
  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}`;
5496
4064
  }
5497
4065
  // Billing cache check — warn if quota is approaching limit.
@@ -5513,6 +4081,8 @@ const plugin = {
5513
4081
  free_writes_used: billingData.free_writes_used ?? 0,
5514
4082
  free_writes_limit: billingData.free_writes_limit ?? 0,
5515
4083
  features: billingData.features,
4084
+ // Relay's authoritative chain_id → drives the chain override verbatim (#402).
4085
+ chain_id: billingData.chain_id,
5516
4086
  checked_at: Date.now(),
5517
4087
  };
5518
4088
  writeBillingCache(cache);
@@ -5521,7 +4091,7 @@ const plugin = {
5521
4091
  if (cache && cache.free_writes_limit > 0) {
5522
4092
  const usageRatio = cache.free_writes_used / cache.free_writes_limit;
5523
4093
  if (usageRatio >= QUOTA_WARNING_THRESHOLD) {
5524
- 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.`;
4094
+ 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.`;
5525
4095
  }
5526
4096
  }
5527
4097
  }
@@ -5692,7 +4262,7 @@ const plugin = {
5692
4262
  // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
5693
4263
  const hookQueryIntent = detectQueryIntent(evt.prompt);
5694
4264
  const reranked = rerank(evt.prompt, queryEmbedding ?? [], rerankerCandidates, 8, INTENT_WEIGHTS[hookQueryIntent],
5695
- /* applySourceWeights (Retrieval v2 Tier 1) */ true);
4265
+ /* applySourceWeights (Retrieval v2 Tier 1) */ false);
5696
4266
  // Update hot cache with reranked results.
5697
4267
  try {
5698
4268
  if (pluginHotCache) {
@@ -5714,15 +4284,16 @@ const plugin = {
5714
4284
  if (reranked.length === 0)
5715
4285
  return undefined;
5716
4286
  // Relevance gate removed in rc.22 (see recall tool comment).
5717
- // 7. Build context string.
5718
- const lines = reranked.map((m, i) => {
4287
+ // 7. Build context string using core's unified recall formatter (adds dates + header).
4288
+ const recallItems = reranked.map((m) => {
5719
4289
  const meta = hookMetaMap.get(m.id);
5720
- const importance = meta?.importance ?? 5;
5721
- const age = meta?.age ?? '';
5722
- const typeTag = meta?.category ? `[${meta.category}] ` : '';
5723
- return `${i + 1}. ${typeTag}${m.text} (importance: ${importance}/10, ${age})`;
4290
+ return {
4291
+ category: meta?.category ?? 'claim',
4292
+ text: m.text,
4293
+ created_at: m.createdAt ?? 0,
4294
+ };
5724
4295
  });
5725
- const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
4296
+ const contextString = getSmartImportWasm().formatRecallContext(JSON.stringify(recallItems), BigInt(Math.floor(Date.now() / 1000)));
5726
4297
  return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
5727
4298
  }
5728
4299
  // --- Server mode (existing behavior) ---
@@ -5793,18 +4364,18 @@ const plugin = {
5793
4364
  // 6. Re-rank with BM25 + cosine + RRF fusion (intent-weighted).
5794
4365
  const srvHookIntent = detectQueryIntent(evt.prompt);
5795
4366
  const reranked = rerank(evt.prompt, queryEmbedding ?? [], rerankerCandidates, 8, INTENT_WEIGHTS[srvHookIntent],
5796
- /* applySourceWeights (Retrieval v2 Tier 1) */ true);
4367
+ /* applySourceWeights (Retrieval v2 Tier 1) */ false);
5797
4368
  if (reranked.length === 0)
5798
4369
  return undefined;
5799
4370
  // Relevance gate removed in rc.22 (see recall tool comment).
5800
- // 7. Build context string.
5801
- const lines = reranked.map((m, i) => {
5802
- const meta = hookMetaMap.get(m.id);
5803
- const importance = meta?.importance ?? 5;
5804
- const age = meta?.age ?? '';
5805
- return `${i + 1}. ${m.text} (importance: ${importance}/10, ${age})`;
5806
- });
5807
- const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
4371
+ // 7. Build context string using core's unified recall formatter (adds dates + header).
4372
+ // Server mode has no category metadata, so we use 'claim' as default.
4373
+ const srvRecallItems = reranked.map((m) => ({
4374
+ category: 'claim',
4375
+ text: m.text,
4376
+ created_at: m.createdAt ?? 0,
4377
+ }));
4378
+ const contextString = getSmartImportWasm().formatRecallContext(JSON.stringify(srvRecallItems), BigInt(Math.floor(Date.now() / 1000)));
5808
4379
  return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
5809
4380
  }
5810
4381
  catch (err) {
@@ -5901,6 +4472,14 @@ const plugin = {
5901
4472
  // surface (scanner constraint: a single file may not contain both
5902
4473
  // fs.read* AND outbound-request trigger words). Deps are passed in
5903
4474
  // here with neutral aliases for the same reason.
4475
+ //
4476
+ // Lifecycle (rc.20, #402): register() can run more than once per process
4477
+ // (OpenClaw's SIGUSR1 restarts are IN-PROCESS, so the module cache and any
4478
+ // running poller survive). No guard is needed here — startTrajectoryPoller
4479
+ // holds a module-global singleton and stops the previous poller before
4480
+ // starting a new one, and each tick self-terminates if the plugin's own
4481
+ // module file is gone (uninstalled/replaced). This prevents the poller
4482
+ // accumulation + zombie-old-version submitters seen on pop-os.
5904
4483
  // ---------------------------------------------------------------
5905
4484
  startTrajectoryPoller({
5906
4485
  logger: api.logger,
@@ -5941,28 +4520,26 @@ const plugin = {
5941
4520
  await storeExtractedFacts(facts, api.logger);
5942
4521
  }
5943
4522
  turnsSinceLastExtraction = 0; // Reset C3 counter on compaction.
5944
- // Session debriefafter regular extraction.
5945
- // v1 mapping: DebriefItem { type: 'summary'|'context' } →
5946
- // v1 type 'summary' (always, since context → claim would lose
5947
- // the "this is a session summary" signal) + source 'derived'
5948
- // (session debrief is a derived synthesis by definition).
4523
+ // Session Crystal (am-1) one structured summary replaces 5 free-form debrief items.
4524
+ // Stored as v1 summary + metadata.subtype="session_crystal" for filtered recall.
5949
4525
  try {
5950
4526
  const storedTexts = facts.map((f) => f.text);
5951
- const debriefItems = await extractDebrief(evt.messages, storedTexts);
5952
- if (debriefItems.length > 0) {
5953
- const debriefFacts = debriefItems.map((d) => ({
5954
- text: d.text,
4527
+ const crystal = await extractCrystal(evt.messages, storedTexts, 'coding');
4528
+ if (crystal) {
4529
+ const crystalFact = {
4530
+ text: crystal.narrative,
5955
4531
  type: 'summary',
5956
4532
  source: 'derived',
5957
- importance: d.importance,
4533
+ importance: crystal.importance,
5958
4534
  action: 'ADD',
5959
- }));
5960
- await storeExtractedFacts(debriefFacts, api.logger, 'openclaw_debrief');
5961
- api.logger.info(`Session debrief: stored ${debriefItems.length} items`);
4535
+ crystalMetadata: crystal.metadata,
4536
+ };
4537
+ await storeExtractedFacts([crystalFact], api.logger, 'openclaw_debrief');
4538
+ api.logger.info('Session Crystal stored');
5962
4539
  }
5963
4540
  }
5964
4541
  catch (debriefErr) {
5965
- api.logger.warn(`before_compaction debrief failed: ${debriefErr instanceof Error ? debriefErr.message : String(debriefErr)}`);
4542
+ api.logger.warn(`before_compaction Crystal failed: ${debriefErr instanceof Error ? debriefErr.message : String(debriefErr)}`);
5966
4543
  }
5967
4544
  }
5968
4545
  catch (err) {
@@ -5996,28 +4573,26 @@ const plugin = {
5996
4573
  await storeExtractedFacts(facts, api.logger);
5997
4574
  }
5998
4575
  turnsSinceLastExtraction = 0; // Reset C3 counter on reset.
5999
- // Session debriefafter regular extraction.
6000
- // v1 mapping: DebriefItem { type: 'summary'|'context' } →
6001
- // v1 type 'summary' (always, since context → claim would lose
6002
- // the "this is a session summary" signal) + source 'derived'
6003
- // (session debrief is a derived synthesis by definition).
4576
+ // Session Crystal (am-1) one structured summary replaces 5 free-form debrief items.
4577
+ // Stored as v1 summary + metadata.subtype="session_crystal" for filtered recall.
6004
4578
  try {
6005
4579
  const storedTexts = facts.map((f) => f.text);
6006
- const debriefItems = await extractDebrief(evt.messages, storedTexts);
6007
- if (debriefItems.length > 0) {
6008
- const debriefFacts = debriefItems.map((d) => ({
6009
- text: d.text,
4580
+ const crystal = await extractCrystal(evt.messages, storedTexts, 'coding');
4581
+ if (crystal) {
4582
+ const crystalFact = {
4583
+ text: crystal.narrative,
6010
4584
  type: 'summary',
6011
4585
  source: 'derived',
6012
- importance: d.importance,
4586
+ importance: crystal.importance,
6013
4587
  action: 'ADD',
6014
- }));
6015
- await storeExtractedFacts(debriefFacts, api.logger, 'openclaw_debrief');
6016
- api.logger.info(`Session debrief: stored ${debriefItems.length} items`);
4588
+ crystalMetadata: crystal.metadata,
4589
+ };
4590
+ await storeExtractedFacts([crystalFact], api.logger, 'openclaw_debrief');
4591
+ api.logger.info('Session Crystal stored');
6017
4592
  }
6018
4593
  }
6019
4594
  catch (debriefErr) {
6020
- api.logger.warn(`before_reset debrief failed: ${debriefErr instanceof Error ? debriefErr.message : String(debriefErr)}`);
4595
+ api.logger.warn(`before_reset Crystal failed: ${debriefErr instanceof Error ? debriefErr.message : String(debriefErr)}`);
6021
4596
  }
6022
4597
  }
6023
4598
  catch (err) {
@@ -6026,63 +4601,98 @@ const plugin = {
6026
4601
  }
6027
4602
  }, { priority: 5 });
6028
4603
  // ---------------------------------------------------------------
6029
- // 3.3.2-rc.1 (issue #186) — write `.loaded.json` manifest
4604
+ // OpenClaw native memory integration (Task 2.7) — register TR as the
4605
+ // memory backend: the MemoryPluginCapability + the memory_search /
4606
+ // memory_get tools the active-memory sub-agent drives.
6030
4607
  // ---------------------------------------------------------------
6031
4608
  //
6032
- // Final step of register(): drop the success manifest so the agent
6033
- // can `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` to
6034
- // verify which tools bound. Synchronous (see writePluginManifest doc).
6035
- // Never throws diagnostic loss only on I/O failure.
6036
- if (_pluginDirForManifest) {
6037
- try {
6038
- const ok = writePluginManifest(_pluginDirForManifest, {
6039
- loadedAt: Date.now(),
6040
- tools: _registeredToolNames.slice(),
6041
- version: pluginVersion ?? 'unknown',
6042
- // 3.3.8-rc.1 hybrid mode annotation: tools ARE registered with the
6043
- // SDK (required for plugin loader validation), but tool calls are
6044
- // dead-letter on OC 2026.5.2 due to issue #223. Use `tr <cmd>` CLI.
6045
- hybridMode: true,
6046
- hybridCliTools: ['tr status', 'tr pair', 'tr remember', 'tr recall'],
6047
- });
6048
- if (ok) {
6049
- api.logger.info(`TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools + hybridCli=tr, version=${pluginVersion ?? 'unknown'})`);
6050
- }
6051
- }
6052
- catch {
6053
- // Best-effort; helper swallows internally too.
6054
- }
4609
+ // This is THE integration point. For TR to BE the memory backend (not
4610
+ // just a tool plugin), it must register all four against TR's own
4611
+ // pipeline:
4612
+ // 1. api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })
4613
+ // 2. api.registerTool(() => createMemorySearchTool(runtime), { names: ['memory_search'] })
4614
+ // 3. api.registerTool(() => createMemoryGetTool(runtime), { names: ['memory_get'] })
4615
+ //
4616
+ // These registerTool calls go through the real host api.registerTool
4617
+ // directly (the 3.3.2-rc.1 monkey-patch + .loaded.json manifest
4618
+ // machinery were removed in Phase 3 — the conventional names survive
4619
+ // the tool-policy strip in OC 2026.5.x, so the declare-and-dead-letter
4620
+ // dance + manifest are obsolete).
4621
+ //
4622
+ // Deps: buildRecallDeps captures `api.logger` so the closures can call
4623
+ // ensureInitialized lazily on first use. The paired-account context
4624
+ // (authKeyHex / encryptionKey / userId / subgraphOwner) is NOT
4625
+ // resolved here — it's populated by initialize() on the first tool
4626
+ // call. The closures call ensureInitialized internally (see
4627
+ // buildRecallDeps docstring).
4628
+ //
4629
+ // Scanner note: this call is fine inside register() because
4630
+ // register() itself is not scanner-clean (the file pairs config reads
4631
+ // with network calls legitimately). The scanner-clean surface is
4632
+ // native-memory.ts (the wiring helper), which never touches env or
4633
+ // net primitives.
4634
+ //
4635
+ // Graceful degradation: the wiring is wrapped in try/catch so a
4636
+ // failure in the native memory pipeline cannot block plugin load.
4637
+ // NOTE (Phase 3.3): the legacy totalreclaw_* agent tools that used to
4638
+ // serve as the capture fallback were RETIRED in Task 3.2. If this
4639
+ // registration fails, the agent has NO memory surface until the cause
4640
+ // is fixed and the gateway restarted. The before_tool_call gate stays
4641
+ // armed (memory_search/memory_get are simply never registered), and
4642
+ // auto-extraction hooks still fire on the message_received / agent_end
4643
+ // cadence — they write to the subgraph directly, so memories keep
4644
+ // getting captured even if the agent can't read them mid-session.
4645
+ try {
4646
+ registerNativeMemory(api, buildRecallDeps(api.logger));
4647
+ api.logger.info('TotalReclaw: registered native MemoryPluginCapability + memory_search/memory_get tools');
4648
+ }
4649
+ catch (err) {
4650
+ const msg = err instanceof Error ? err.message : String(err);
4651
+ api.logger.warn(`TotalReclaw: native memory capability registration failed — agent memory_search/memory_get UNAVAILABLE until fixed: ${msg}`);
4652
+ }
4653
+ // ---------------------------------------------------------------
4654
+ // Skill auto-register (rc.17 QA finding: plugin installs but the
4655
+ // SKILL.md playbook does not — agents skipped the separate
4656
+ // `openclaw skills install totalreclaw` step and ended up without
4657
+ // pairing / recall instructions). Mirror the bundled SKILL.md +
4658
+ // skill.json from the package root into the workspace skills dir so
4659
+ // OpenClaw's workspace skill scanner discovers them on the next
4660
+ // gateway load. A single `openclaw plugins install` is now enough
4661
+ // for both plugin + skill. Idempotent + never throws (see
4662
+ // skill-register.ts). Lives in a scanner-clean helper because
4663
+ // index.ts already pairs env-derived config with network calls, so
4664
+ // the disk I/O must stay out of this file.
4665
+ // ---------------------------------------------------------------
4666
+ try {
4667
+ // Re-resolve the dist dir here: the earlier `pluginDir` const
4668
+ // lives inside its own inner try/catch scope and is not visible
4669
+ // this far down. The call is pure + cheap (URL parse + dirname).
4670
+ ensureSkillRegistered({
4671
+ pluginDir: nodePath.dirname(fileURLToPath(import.meta.url)),
4672
+ skillsDir: nodePath.join(CONFIG.openclawWorkspace, 'skills'),
4673
+ logger: api.logger,
4674
+ });
4675
+ }
4676
+ catch (err) {
4677
+ const msg = err instanceof Error ? err.message : String(err);
4678
+ api.logger.warn(`TotalReclaw: skill auto-register failed (non-fatal): ${msg}`);
6055
4679
  }
6056
4680
  }
6057
4681
  catch (registerErr) {
6058
4682
  // ---------------------------------------------------------------
6059
- // 3.3.2-rc.1 (issue #186) — write `.error.json` on register() throw
4683
+ // register() threw best-effort log then re-throw so the SDK sees
4684
+ // the original failure. (3.3.2-rc.1 used to write a `.error.json`
4685
+ // marker here; that machinery was retired in Phase 3.4 along with
4686
+ // the `.loaded.json` success manifest. The gateway log is now the
4687
+ // source of truth for register() failures.)
6060
4688
  // ---------------------------------------------------------------
6061
- //
6062
- // Some surface threw out of register(). Drop a structured error
6063
- // marker the agent can grep. Best-effort logging then re-throw so
6064
- // the SDK sees the original failure.
6065
4689
  const errMsg = registerErr instanceof Error ? registerErr.message : String(registerErr);
6066
- const errStack = registerErr instanceof Error ? registerErr.stack : undefined;
6067
4690
  try {
6068
4691
  api.logger.error(`TotalReclaw: register() threw: ${errMsg}`);
6069
4692
  }
6070
4693
  catch {
6071
4694
  // Logger may be unavailable (very early failure path).
6072
4695
  }
6073
- if (_pluginDirForManifest) {
6074
- try {
6075
- writePluginError(_pluginDirForManifest, {
6076
- loadedAt: Date.now(),
6077
- error: errMsg,
6078
- stack: errStack,
6079
- version: 'unknown',
6080
- });
6081
- }
6082
- catch {
6083
- // Best-effort.
6084
- }
6085
- }
6086
4696
  throw registerErr;
6087
4697
  }
6088
4698
  },