@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
package/index.ts CHANGED
@@ -8,37 +8,55 @@
8
8
  /**
9
9
  * TotalReclaw Plugin for OpenClaw
10
10
  *
11
- * Registers runtime tools so OpenClaw can execute TotalReclaw operations:
12
- * - totalreclaw_remember -- store an encrypted memory
13
- * - totalreclaw_recall -- search and decrypt memories
14
- * - totalreclaw_forget -- soft-delete a memory
15
- * - totalreclaw_export -- export all memories (JSON or Markdown)
16
- * - totalreclaw_status -- check billing/subscription status
17
- * - totalreclaw_consolidate -- scan and merge near-duplicate memories
18
- * - totalreclaw_pin -- pin a memory so auto-resolution can never supersede it
19
- * - totalreclaw_unpin -- remove a pin, returning the memory to active status
20
- * - totalreclaw_import_from -- import memories from other tools (Mem0, MCP Memory, etc.)
21
- * - totalreclaw_import_status -- check background import progress
22
- * - totalreclaw_import_abort -- cancel a running background import
23
- * - totalreclaw_upgrade -- create Stripe checkout for Pro upgrade
24
- * - totalreclaw_migrate -- migrate testnet memories to mainnet after Pro upgrade
25
- * - totalreclaw_onboarding_start -- non-secret pointer to the CLI wizard (3.2.0)
26
- * - totalreclaw_setup -- DEPRECATED in 3.2.0; redirects to the CLI wizard
11
+ * Phase 3 OpenClaw native integration. The agent reads memories via the
12
+ * bundled NATIVE tools `memory_search` / `memory_get`, registered through
13
+ * the host's MemoryPluginCapability by `registerNativeMemory` (see
14
+ * `native-memory.ts`). The TrMemorySearchManager adapter binds those tools
15
+ * to TotalReclaw's encrypted subgraph + decrypt + reranker pipeline.
16
+ *
17
+ * The legacy `totalreclaw_*` agent tools (remember / recall / forget / export
18
+ * / status / pin / unpin / retype / set_scope / import_from / import_status
19
+ * / import_abort / upgrade / migrate / onboarding_start / setup /
20
+ * report_qa_bug) were RETIRED in Task 3.2. Their capabilities now live on:
21
+ * - read (recall): native `memory_search` / `memory_get`.
22
+ * - explicit write: CLI `tr remember` (the conventional memory
23
+ * contract ships no agent-facing write tool;
24
+ * auto-extraction handles implicit writes).
25
+ * - curation/lifecycle: CLI `tr forget` / `tr export` + the
26
+ * registerCli `openclaw totalreclaw ...` surface.
27
+ * - import/upgrade: registerCli `openclaw totalreclaw import from ...`
28
+ * / `import status` / `import abort` / `upgrade`
29
+ * (3.3.13 — restored as CLI surfaces; the handlers
30
+ * stayed in this file post-3.2 but had no entry
31
+ * point). NOT on the standalone `tr` binary —
32
+ * import needs the full extraction pipeline that
33
+ * only loads inside the gateway runtime.
34
+ * - onboarding/pair: CLI `tr pair` + the 4 `/plugin/totalreclaw/pair/*`
35
+ * HTTP routes (registerHttpRoute bundle).
36
+ *
37
+ * Hooks registered here:
38
+ * - `before_agent_start` — injects relevant memories into the agent's
39
+ * context (via the MemoryPluginCapability's promptBuilder) and a
40
+ * non-secret onboarding hint when the user has not paired yet.
41
+ * - `before_tool_call` — gates the native `memory_search` / `memory_get`
42
+ * tools behind onboarding state `active` so an unpaired agent gets an
43
+ * actionable pointer to `tr pair --url-pin` instead of the adapter's
44
+ * silent fail-soft empty result (see `tool-gating.ts`).
45
+ * - `agent_end`, `message_received`, `before_reset` — auto-extraction
46
+ * + digest bookkeeping (unchanged).
27
47
  *
28
48
  * Also registers:
29
- * - `before_agent_start` hook that automatically injects relevant memories
30
- * into the agent's context (and a non-secret onboarding hint when the
31
- * user has not completed the CLI setup yet).
32
- * - `before_tool_call` hook that gates every memory tool until onboarding
33
- * state is `active` (3.2.0).
34
- * - `registerCli` subcommand `openclaw totalreclaw onboard` the ONLY
35
- * surface that generates or accepts a recovery phrase. Lives entirely on
36
- * the user's terminal; the phrase never enters an LLM request or a
37
- * session transcript.
49
+ * - `registerCli` subcommand `openclaw totalreclaw ...` pair / onboard /
50
+ * status / pin / unpin / retype / set_scope / import / export. The
51
+ * `onboard` subcommand is the ONLY surface that generates or accepts a
52
+ * recovery phrase; it lives entirely on the user's terminal and the
53
+ * phrase never enters an LLM request or a session transcript.
54
+ * - `registerHttpRoute` the 4 QR-pair endpoints under
55
+ * `/plugin/totalreclaw/pair/*` (browser-facing, plugin-auth).
38
56
  * - `registerCommand` slash command `/totalreclaw {onboard,status}` — a
39
- * non-secret pointer that directs the user to the CLI wizard.
57
+ * non-secret pointer to the CLI wizard.
40
58
  *
41
- * Security: in 3.2.0, the recovery phrase NEVER appears in tool responses,
59
+ * Security: the recovery phrase NEVER appears in tool responses,
42
60
  * `prependContext` blocks, slash-command replies, or any other surface that
43
61
  * is sent to the LLM provider or persisted to the session transcript. See
44
62
  * `docs/plans/2026-04-20-plugin-320-secure-onboarding.md` in the internal
@@ -135,13 +153,6 @@ import {
135
153
  validatePinArgs,
136
154
  type PinOpDeps,
137
155
  } from './pin.js';
138
- import {
139
- executeRetype,
140
- executeSetScope,
141
- validateRetypeArgs,
142
- validateSetScopeArgs,
143
- type RetypeSetScopeDeps,
144
- } from './retype-setscope.js';
145
156
  import {
146
157
  runNonInteractiveOnboard,
147
158
  type NonInteractiveOnboardResult,
@@ -169,16 +180,9 @@ import {
169
180
  detectPartialInstall,
170
181
  clearPartialInstallMarker,
171
182
  patchOpenClawConfig,
172
- writePluginManifest,
173
- writePluginError,
174
- readPluginLoadedManifest,
175
183
  checkCredentialsFileMode,
176
- deletePairPendingFile,
177
- defaultPairPendingPath,
178
184
  type OnboardingState,
179
185
  } from './fs-helpers.js';
180
- import { maybeStartAutoPair } from './auto-pair-on-load.js';
181
- import { installBeforeAgentStartHook as installPairPendingHook } from './pair-pending-injection.js';
182
186
  import { isRcBuild } from './qa-bug-report.js';
183
187
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
184
188
  import {
@@ -194,6 +198,9 @@ import {
194
198
  import { detectFirstRun, buildWelcomePrepend, type GatewayMode } from './first-run.js';
195
199
  import { buildPairRoutes } from './pair-http.js';
196
200
  import { detectGatewayHost } from './gateway-url.js';
201
+ import { registerNativeMemory, type TrNativeMemoryDeps } from './native-memory.js';
202
+ import { ensureSkillRegistered } from './skill-register.js';
203
+ import type { TrFact, TrPinnedFact, TrQuotaState } from './memory-runtime.js';
197
204
  import { validateMnemonic } from '@scure/bip39';
198
205
  import { wordlist } from '@scure/bip39/wordlists/english.js';
199
206
  import crypto from 'node:crypto';
@@ -439,16 +446,18 @@ function buildPairingUrl(
439
446
  // issue #110 fix 4: inside Docker the LAN IP is container-internal
440
447
  // and useless. Loopback localhost only works for `docker exec`
441
448
  // tests. The CORRECT pair URL for Docker is the relay-brokered
442
- // path served by the `totalreclaw_pair` agent tool (CONFIG.pairMode
443
- // === 'relay' since rc.11). The CLI-only path here cannot mint a
444
- // relay session synchronously (the relay handshake needs a WS
445
- // round-trip), so we emit the loopback URL with a LOUD warning
446
- // pointing the operator at the agent tool / publicUrl override.
449
+ // path served by `tr pair` / the `/plugin/totalreclaw/pair/*` HTTP
450
+ // routes (CONFIG.pairMode === 'relay' since rc.11). The CLI-only
451
+ // path here cannot mint a relay session synchronously (the relay
452
+ // handshake needs a WS round-trip), so we emit the loopback URL
453
+ // with a LOUD warning pointing the operator at the pair CLI /
454
+ // publicUrl override.
447
455
  api.logger.warn(
448
456
  `TotalReclaw: Docker container detected — pairing URL falling back to ` +
449
457
  `http://localhost:${port}, which is unreachable from the host browser. ` +
450
- `Use the totalreclaw_pair AGENT TOOL (relay-brokered, universally reachable) ` +
451
- `instead of the CLI fallback, OR set plugins.entries.totalreclaw.config.publicUrl ` +
458
+ `Run \`tr pair --url-pin\` (or \`openclaw totalreclaw pair generate --url-pin-only\`) ` +
459
+ `on the gateway host to mint a relay-brokered pair URL that reaches the host browser, ` +
460
+ `OR set plugins.entries.totalreclaw.config.publicUrl ` +
452
461
  `to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
453
462
  `Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
454
463
  `air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`,
@@ -661,6 +670,11 @@ function getMaxFactsPerExtraction(): number {
661
670
  }
662
671
  const cache = readBillingCache();
663
672
  if (cache?.features?.max_facts_per_extraction != null) return cache.features.max_facts_per_extraction;
673
+ // Env override is read in config.ts (env access is centralized there so
674
+ // the env-harvesting scanner-sim stays a no-op for index.ts).
675
+ if (CONFIG.maxFactsPerExtraction && CONFIG.maxFactsPerExtraction > 0) {
676
+ return CONFIG.maxFactsPerExtraction;
677
+ }
664
678
  return MAX_FACTS_PER_EXTRACTION;
665
679
  }
666
680
 
@@ -678,9 +692,10 @@ const MEMORY_HEADER = `# Memory
678
692
 
679
693
  > **TotalReclaw is active.** Your encrypted memories are loaded automatically
680
694
  > at the start of each conversation — no need to search this file for them.
681
- > Use \`totalreclaw_remember\` to store new memories and \`totalreclaw_recall\`
682
- > to search. Do NOT write user facts, preferences, or decisions to this file.
683
- > This file is for workspace-level notes only (non-sensitive).
695
+ > Recall is automatic via the memory_search tool; to explicitly store a fact,
696
+ > run \`tr remember "<text>"\` in a shell. Do NOT write user facts,
697
+ > preferences, or decisions to this file. This file is for workspace-level
698
+ > notes only (non-sensitive).
684
699
 
685
700
  `;
686
701
 
@@ -846,13 +861,12 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
846
861
 
847
862
  if (!masterPassword) {
848
863
  needsSetup = true;
849
- // 3.3.13replaced the stale "run `openclaw totalreclaw onboard` in a
850
- // terminal to set up" log line. The auto-pair-on-load flow (kicked off
851
- // from register()) now writes ~/.totalreclaw/.pair-pending.json with
852
- // the URL + PIN the agent surfaces to the user on the next turn. The
853
- // chat agent no longer hallucinates pair URLs — see the Pop-OS QA
854
- // 2026-05-10 root-cause note in auto-pair-on-load.ts.
855
- logger.info('TotalReclaw: no credentials found; auto-pair-on-load will surface a pair URL.');
864
+ // No credentials yet pairing is user-initiated via `tr pair` or the
865
+ // `/plugin/totalreclaw/pair/*` HTTP route (QR scan flow). The plugin
866
+ // does not auto-open a pair session on load (the 3.3.13 auto-pair-on-
867
+ // load state machine was retired in Phase 3.4 pairing is now
868
+ // explicitly user-triggered per the native-integration design).
869
+ logger.info('TotalReclaw: no credentials found. Run `tr pair` (or ask the agent to) to complete setup.');
856
870
  return;
857
871
  }
858
872
 
@@ -992,12 +1006,16 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
992
1006
  const billingData = await resp.json() as Record<string, unknown>;
993
1007
  const tier = billingData.tier as string;
994
1008
  const expiresAt = billingData.expires_at as string | undefined;
995
- // Populate billing cache for future use.
1009
+ // Populate billing cache for future use. Copy the relay's
1010
+ // authoritative chain_id + data_edge_address so they land on disk and
1011
+ // drive the runtime chain + DataEdge overrides verbatim (#402, #460).
996
1012
  writeBillingCache({
997
1013
  tier: tier || 'free',
998
1014
  free_writes_used: (billingData.free_writes_used as number) ?? 0,
999
1015
  free_writes_limit: (billingData.free_writes_limit as number) ?? 0,
1000
1016
  features: billingData.features as BillingCache['features'] | undefined,
1017
+ chain_id: billingData.chain_id as number | undefined,
1018
+ data_edge_address: billingData.data_edge_address as string | undefined,
1001
1019
  checked_at: Date.now(),
1002
1020
  });
1003
1021
  if (tier === 'pro' && expiresAt) {
@@ -1038,10 +1056,16 @@ function isDocker(): boolean {
1038
1056
  }
1039
1057
 
1040
1058
  function buildSetupErrorMsg(): string {
1041
- return 'TotalReclaw setup required. Use the `totalreclaw_setup` tool with a 12-word BIP-39 recovery phrase.\n\n' +
1042
- '1. Ask the user if they have an existing recovery phrase, or generate a new one with `npx @totalreclaw/mcp-server setup`.\n' +
1043
- '2. Call `totalreclaw_setup` with the phrase no gateway restart needed.\n' +
1044
- ' (Optional: set TOTALRECLAW_SELF_HOSTED=true if using your own server instead of the managed service.)';
1059
+ // NOTE: the legacy `totalreclaw_setup` agent tool was retired in 3.2.0
1060
+ // (phrase-safety: the agent must never accept or relay a recovery phrase).
1061
+ // The ONLY setup surface is the QR-pair flow: the agent cannot mint a
1062
+ // pair URL itself, so it must direct the user to the CLI. This message is
1063
+ // thrown by `requireFullSetup` (currently only reached via dead 3.2 tool
1064
+ // handlers; kept accurate so any future caller gets the correct pointer).
1065
+ return 'TotalReclaw setup required. Pairing is QR-only — the recovery phrase is generated and encrypted in-browser and never enters this chat.\n\n' +
1066
+ 'Run `tr pair --url-pin` on the gateway host (or `openclaw totalreclaw pair generate --url-pin-only`) ' +
1067
+ 'and hand the user the returned `url` and `pin`. The user opens the URL in a browser to complete pairing. ' +
1068
+ 'Do NOT ask the user for a recovery phrase and do NOT attempt to generate or relay one yourself.';
1045
1069
  }
1046
1070
 
1047
1071
  function buildSetupErrorMsgLegacy(): string {
@@ -1077,8 +1101,9 @@ const SETUP_ERROR_MSG = buildSetupErrorMsg();
1077
1101
  * Ensure `initialize()` has completed (runs at most once).
1078
1102
  *
1079
1103
  * If `needsSetup` is true after init, attempts a hot-reload from
1080
- * credentials.json in case the mnemonic was written there by a
1081
- * `totalreclaw_setup` tool call or `npx @totalreclaw/mcp-server setup`.
1104
+ * credentials.json in case the mnemonic was just written there by the
1105
+ * pair-completion HTTP route (`/plugin/totalreclaw/pair/respond`
1106
+ * `completePairing`) or the `tr pair` CLI on another process.
1082
1107
  */
1083
1108
  async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<void> {
1084
1109
  if (!initPromise) {
@@ -1087,7 +1112,7 @@ async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<v
1087
1112
  await initPromise;
1088
1113
 
1089
1114
  // Hot-reload: if setup is still needed, check if credentials.json
1090
- // now has a mnemonic (written by totalreclaw_setup or MCP setup CLI).
1115
+ // now has a mnemonic (written by the pair HTTP route / `tr pair` CLI).
1091
1116
  if (needsSetup) {
1092
1117
  await attemptHotReload(logger);
1093
1118
  }
@@ -1097,9 +1122,9 @@ async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<v
1097
1122
  * Attempt to hot-reload credentials from credentials.json.
1098
1123
  *
1099
1124
  * Called when `needsSetup` is true — checks if credentials.json contains
1100
- * a mnemonic (written by the `totalreclaw_setup` tool or MCP setup CLI).
1101
- * If found, re-derives keys and completes initialization without requiring
1102
- * a gateway restart.
1125
+ * a mnemonic (written by the pair-completion HTTP route or `tr pair` CLI
1126
+ * on another process). If found, re-derives keys and completes
1127
+ * initialization without requiring a gateway restart.
1103
1128
  */
1104
1129
  async function attemptHotReload(logger: OpenClawPluginApi['logger']): Promise<void> {
1105
1130
  try {
@@ -1125,9 +1150,18 @@ async function attemptHotReload(logger: OpenClawPluginApi['logger']): Promise<vo
1125
1150
  /**
1126
1151
  * Force re-initialization with a specific mnemonic.
1127
1152
  *
1128
- * Called by the `totalreclaw_setup` tool. Clears stale credentials from
1129
- * disk so that `initialize()` treats this as a fresh registration and
1130
- * persists the NEW mnemonic + freshly derived salt/userId.
1153
+ * LEGACY (Phase 3.2): the only caller was the `totalreclaw_setup` agent
1154
+ * tool, which was retired because accepting a recovery phrase via an agent
1155
+ * tool violated phrase-safety (the phrase must never enter an LLM context).
1156
+ * The function currently has NO callers; it is retained because the
1157
+ * credential-rotation invariant documented below still describes a real
1158
+ * trap for any future credential-rotating surface, and removing the body
1159
+ * would lose that institutional knowledge. Safe to delete once confirmed
1160
+ * unused across the whole plugin tree.
1161
+ *
1162
+ * Clears stale credentials from disk so that `initialize()` treats this as
1163
+ * a fresh registration and persists the NEW mnemonic + freshly derived
1164
+ * salt/userId.
1131
1165
  *
1132
1166
  * Without clearing credentials.json first, `initialize()` would load the
1133
1167
  * OLD salt and userId, derive keys from (new mnemonic + old salt), skip
@@ -1797,7 +1831,7 @@ function relativeTime(isoOrMs: string | number): string {
1797
1831
  * Configurable via TOTALRECLAW_MIN_IMPORTANCE env var (default: 3).
1798
1832
  *
1799
1833
  * NOTE: This filter is ONLY applied to auto-extraction (hooks).
1800
- * The explicit `totalreclaw_remember` tool always stores regardless of importance.
1834
+ * The explicit `tr remember` CLI always stores regardless of importance.
1801
1835
  */
1802
1836
  const MIN_IMPORTANCE_THRESHOLD = CONFIG.minImportance;
1803
1837
 
@@ -2219,7 +2253,7 @@ async function storeExtractedFacts(
2219
2253
  // Submit subgraph payloads one fact at a time (sequential single-call UserOps).
2220
2254
  // Batch executeBatch UserOps have persistent gas estimation issues on Base Sepolia
2221
2255
  // that cause on-chain reverts. Single-fact UserOps use the simpler submitFactOnChain
2222
- // path which works reliably (same path as totalreclaw_remember). Each submission
2256
+ // path which works reliably (same path the `tr remember` CLI uses). Each submission
2223
2257
  // polls for receipt (120s) before proceeding, so nonce is consumed before the next.
2224
2258
  let batchError: string | undefined;
2225
2259
  if (pendingPayloads.length > 0 && isSubgraphMode()) {
@@ -2272,11 +2306,12 @@ async function storeExtractedFacts(
2272
2306
  }
2273
2307
 
2274
2308
  // ---------------------------------------------------------------------------
2275
- // Import handler (for totalreclaw_import_from tool)
2309
+ // Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
2276
2310
  // ---------------------------------------------------------------------------
2277
2311
 
2278
2312
  /**
2279
- * Handle import_from tool calls in the plugin context.
2313
+ * Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
2314
+ * agent tool before Phase 3.2 retired the agent tools).
2280
2315
  *
2281
2316
  * Two paths:
2282
2317
  * 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
@@ -2423,7 +2458,7 @@ async function handlePluginImportFrom(
2423
2458
  estimated_batches: estimatedBatches,
2424
2459
  estimated_minutes: estimatedMinutes,
2425
2460
  estimated_completion_iso: initialState.estimated_completion_iso,
2426
- message: `Import started in background. ~${estimatedMinutes} min for ${totalChunks} chunks. Ask "how's the import?" to check progress with totalreclaw_import_status.`,
2461
+ 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).`,
2427
2462
  warnings: parseResult.warnings,
2428
2463
  };
2429
2464
  }
@@ -3003,7 +3038,7 @@ async function handleImportStatus(
3003
3038
  if (!state) return { error: `No import found with id: ${importId}` };
3004
3039
  } else {
3005
3040
  state = readMostRecentActiveImport();
3006
- if (!state) return { status: 'no_active_import', message: 'No active import found. Start one with totalreclaw_import_from.' };
3041
+ if (!state) 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.)' };
3007
3042
  }
3008
3043
 
3009
3044
  // 1h freshness guard: mark stale imports as failed and prompt user to resume.
@@ -3015,7 +3050,7 @@ async function handleImportStatus(
3015
3050
  status: 'failed',
3016
3051
  stale: true,
3017
3052
  facts_stored: state.facts_stored,
3018
- message: 'Import appears stale — no progress in 1 hour. Resume with totalreclaw_import_from using the same file and resume_id.',
3053
+ 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.',
3019
3054
  resume_id: state.import_id,
3020
3055
  };
3021
3056
  }
@@ -3071,6 +3106,339 @@ async function handleImportAbort(
3071
3106
  };
3072
3107
  }
3073
3108
 
3109
+ // ---------------------------------------------------------------------------
3110
+ // buildRecallDeps — bind the real recall pipeline into the closures the
3111
+ // native MemoryPluginCapability wiring helper (Task 2.7) consumes.
3112
+ //
3113
+ // WHY THIS LIVES IN index.ts (not in native-memory.ts):
3114
+ // The real `recall` / `getById` closures must reach unexported index.ts
3115
+ // helpers (generateBlindIndices, generateEmbedding, getLSHHasher,
3116
+ // computeCandidatePool, isDigestBlob, readClaimFromBlob, searchSubgraph,
3117
+ // searchSubgraphBroadened, getSubgraphFactCount, fetchFactById,
3118
+ // ensureInitialized) AND module-level state (authKeyHex, encryptionKey,
3119
+ // userId, subgraphOwner). Lifting these out of index.ts is a high
3120
+ // blast-radius refactor with scanner-trap risk — out of scope for 2.7.
3121
+ //
3122
+ // native-memory.ts (the wiring helper) is pure orchestration and stays
3123
+ // scanner-trivial; the closures stay here where the rest of the plugin's
3124
+ // network surface lives.
3125
+ //
3126
+ // LAZY CONTEXT RESOLUTION:
3127
+ // The paired-account context (authKeyHex / encryptionKey / userId /
3128
+ // subgraphOwner) is NOT resolved synchronously at register() time. It is
3129
+ // populated by `initialize()` on the first tool/hook call via
3130
+ // `ensureInitialized()`. So each closure calls `ensureInitialized(logger)`
3131
+ // internally before touching the module-level state — the same lazy-init
3132
+ // seam the retired totalreclaw_recall tool used to use (then via
3133
+ // `requireFullSetup`).
3134
+ //
3135
+ // If setup is incomplete (no credentials), `ensureInitialized` returns
3136
+ // with `needsSetup=true`; the closures then surface a typed error
3137
+ // (`getMemorySearchManager` will return `{ manager: null, error }` from
3138
+ // the runtime wrapper, which the tools convert into the disabled-result
3139
+ // payload the agent recognizes).
3140
+ //
3141
+ // SCANNER NOTE:
3142
+ // This file (index.ts) is NOT scanner-clean — it is the plugin's network
3143
+ // surface and contains the env+net pair legitimately (centralized via
3144
+ // config.ts reads + relay.ts). The closures here CALL the scanner-clean
3145
+ // subgraph-search / vault-crypto / reranker modules but do not add any
3146
+ // NEW env-harvesting or exfiltration pattern: they read only the
3147
+ // already-resolved module-level state. `check-scanner` was already
3148
+ // non-zero on index.ts before this task (the file legitimately pairs
3149
+ // config reads with network calls); the closures do not change that
3150
+ // posture. The NEW files (native-memory.ts, register-native.test.ts)
3151
+ // are scanner-clean by construction (verified).
3152
+ // ---------------------------------------------------------------------------
3153
+
3154
+ /**
3155
+ * Build the deps for the native MemoryPluginCapability. Returns the
3156
+ * `recall` / `getById` closures bound to the real subgraph + decrypt +
3157
+ * reranker pipeline, plus optional `quota` / `pinned` prompt-builder inputs
3158
+ * (currently defaulted — see TODO(task 2.7b / H1 gate) below).
3159
+ *
3160
+ * `logger` is threaded in so the closures can call `ensureInitialized` (the
3161
+ * lazy-init seam used by every other tool handler in this file).
3162
+ *
3163
+ * @param logger the OpenClaw plugin logger (forwarded into ensureInitialized)
3164
+ */
3165
+ function buildRecallDeps(logger: OpenClawPluginApi['logger']): TrNativeMemoryDeps {
3166
+ // -------------------------------------------------------------------
3167
+ // recall(): the load-bearing closure. This is the search/decrypt/rerank
3168
+ // pipeline that backs the native memory_search tool via the
3169
+ // TrMemorySearchManager adapter. It replaced the retired totalreclaw_recall
3170
+ // agent tool handler (Phase 3.2) MINUS the tool-level result formatting +
3171
+ // hot-cache bookkeeping (those are tool concerns; the native memory
3172
+ // pipeline only needs the TrFact[]). Returns TrFact[] shaped for the
3173
+ // TrMemorySearchManager adapter (memory-runtime.ts).
3174
+ // -------------------------------------------------------------------
3175
+ const recall: TrNativeMemoryDeps['recall'] = async (
3176
+ query,
3177
+ opts,
3178
+ ): Promise<TrFact[]> => {
3179
+ // Lazy-init: this is the first seam the closure hits. If the user
3180
+ // is not paired, ensureInitialized returns with needsSetup=true; we
3181
+ // surface that as an empty result (the before_tool_call gate in
3182
+ // tool-gating.ts normally intercepts memory_search BEFORE this runs
3183
+ // when state != active, but fail-soft here too — a search with no
3184
+ // credentials returning [] is benign; the agent treats empty
3185
+ // results as "no memories found").
3186
+ await ensureInitialized(logger);
3187
+
3188
+ // Guard: if setup is incomplete OR we're missing the pipeline state,
3189
+ // return []. This is fail-soft: the user sees "no memories" rather
3190
+ // than a thrown error out of the memory_search tool boundary.
3191
+ if (needsSetup || !encryptionKey || !authKeyHex) return [];
3192
+ // subgraphOwner may be null on SA-derivation failure (see initialize()).
3193
+ // The subgraph path requires a non-null owner (Bytes!); if missing,
3194
+ // we cannot run recall — return [].
3195
+ if (isSubgraphMode() && !subgraphOwner) return [];
3196
+
3197
+ const k = Math.min(opts?.maxResults ?? 8, 20);
3198
+
3199
+ // 1. Generate word trapdoors (blind indices for the query).
3200
+ const wordTrapdoors = generateBlindIndices(query);
3201
+
3202
+ // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
3203
+ let queryEmbedding: number[] | null = null;
3204
+ let lshTrapdoors: string[] = [];
3205
+ try {
3206
+ queryEmbedding = await generateEmbedding(query, { isQuery: true });
3207
+ const hasher = getLSHHasher(logger);
3208
+ if (hasher && queryEmbedding) {
3209
+ lshTrapdoors = hasher.hash(queryEmbedding);
3210
+ }
3211
+ } catch (err) {
3212
+ const msg = err instanceof Error ? err.message : String(err);
3213
+ logger.warn(`native recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
3214
+ }
3215
+
3216
+ // 3. Merge word + LSH trapdoors.
3217
+ const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
3218
+ if (allTrapdoors.length === 0) return [];
3219
+
3220
+ // 4. Build reranker candidates from the decrypted subgraph results.
3221
+ const rerankerCandidates: RerankerCandidate[] = [];
3222
+
3223
+ if (isSubgraphMode()) {
3224
+ // --- Subgraph search path (the canonical path for managed installs) ---
3225
+ const factCount = await getSubgraphFactCount(subgraphOwner || userId!, authKeyHex);
3226
+ const pool = computeCandidatePool(factCount);
3227
+ let subgraphResults = await searchSubgraph(subgraphOwner || userId!, allTrapdoors, pool, authKeyHex);
3228
+
3229
+ // Broadened search + merge — vocabulary-mismatch safety net (mirrors
3230
+ // the recall tool: ensures "preferences" still matches "prefer").
3231
+ try {
3232
+ const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId!, pool, authKeyHex);
3233
+ const existingIds = new Set(subgraphResults.map((r) => r.id));
3234
+ for (const br of broadenedResults) {
3235
+ if (!existingIds.has(br.id)) subgraphResults.push(br);
3236
+ }
3237
+ } catch {
3238
+ // best-effort
3239
+ }
3240
+
3241
+ for (const result of subgraphResults) {
3242
+ try {
3243
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
3244
+ if (isDigestBlob(docJson)) continue;
3245
+ const doc = readClaimFromBlob(docJson);
3246
+
3247
+ let decryptedEmbedding: number[] | undefined;
3248
+ if (result.encryptedEmbedding) {
3249
+ try {
3250
+ decryptedEmbedding = JSON.parse(
3251
+ decryptFromHex(result.encryptedEmbedding, encryptionKey),
3252
+ );
3253
+ } catch {
3254
+ // embedding decryption failed -- proceed without it
3255
+ }
3256
+ }
3257
+
3258
+ // Dim-mismatch fallback: regenerate the embedding from text so
3259
+ // the reranker's cosine component stays meaningful across model
3260
+ // upgrades. Mirrors the recall tool exactly.
3261
+ if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
3262
+ try {
3263
+ decryptedEmbedding = await generateEmbedding(doc.text);
3264
+ } catch {
3265
+ decryptedEmbedding = undefined;
3266
+ }
3267
+ }
3268
+
3269
+ rerankerCandidates.push({
3270
+ id: result.id,
3271
+ text: doc.text,
3272
+ embedding: decryptedEmbedding,
3273
+ importance: doc.importance / 10,
3274
+ createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
3275
+ // Retrieval v2 Tier 1 source — surfaced so applySourceWeights
3276
+ // could multiply the final RRF score (left false here to match
3277
+ // the recall tool's current behavior; TODO(task 2.7b): wire
3278
+ // source weighting for the native path at the H1 QA gate).
3279
+ source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
3280
+ });
3281
+ } catch {
3282
+ // Skip candidates we cannot decrypt (corrupted / wrong key).
3283
+ }
3284
+ }
3285
+ } else {
3286
+ // --- Server search path (legacy / self-hosted) ---
3287
+ // The non-subgraph path uses apiClient.search. The native memory
3288
+ // pipeline is intended for managed (subgraph) installs, but we keep
3289
+ // parity with the recall tool so self-hosted users get recall too.
3290
+ if (!apiClient || !userId) return [];
3291
+ const factCount = await getFactCount(logger);
3292
+ const pool = computeCandidatePool(factCount);
3293
+ const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
3294
+
3295
+ for (const candidate of candidates) {
3296
+ try {
3297
+ const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
3298
+ if (isDigestBlob(docJson)) continue;
3299
+ const doc = readClaimFromBlob(docJson);
3300
+
3301
+ let decryptedEmbedding: number[] | undefined;
3302
+ if (candidate.encrypted_embedding) {
3303
+ try {
3304
+ decryptedEmbedding = JSON.parse(
3305
+ decryptFromHex(candidate.encrypted_embedding, encryptionKey),
3306
+ );
3307
+ } catch {
3308
+ // embedding decryption failed
3309
+ }
3310
+ }
3311
+
3312
+ if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
3313
+ try {
3314
+ decryptedEmbedding = await generateEmbedding(doc.text);
3315
+ } catch {
3316
+ decryptedEmbedding = undefined;
3317
+ }
3318
+ }
3319
+
3320
+ rerankerCandidates.push({
3321
+ id: candidate.fact_id,
3322
+ text: doc.text,
3323
+ embedding: decryptedEmbedding,
3324
+ importance: doc.importance / 10,
3325
+ createdAt: typeof candidate.timestamp === 'number'
3326
+ ? candidate.timestamp / 1000
3327
+ : new Date(candidate.timestamp).getTime() / 1000,
3328
+ source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
3329
+ });
3330
+ } catch {
3331
+ // Skip candidates we cannot decrypt.
3332
+ }
3333
+ }
3334
+ }
3335
+
3336
+ // 5. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
3337
+ const queryIntent = detectQueryIntent(query);
3338
+ const reranked = rerank(
3339
+ query,
3340
+ queryEmbedding ?? [],
3341
+ rerankerCandidates,
3342
+ k,
3343
+ INTENT_WEIGHTS[queryIntent],
3344
+ // applySourceWeights=false — matches the recall tool's current
3345
+ // behavior. TODO(task 2.7b / H1 gate): flip to true for the native
3346
+ // path so Retrieval v2 Tier 1 source weighting takes effect.
3347
+ false,
3348
+ );
3349
+
3350
+ // 6. Map RerankerResult -> TrFact. The score field is rrfScore (the
3351
+ // final fused + weighted score the manager's defensive sort uses).
3352
+ return reranked.map((m) => ({
3353
+ id: m.id,
3354
+ plaintext: m.text,
3355
+ score: m.rrfScore,
3356
+ // pinned is intentionally not surfaced here today — pinned status
3357
+ // lives in claim metadata and there's no clean read-side aggregate
3358
+ // to lift in this task. See getById + pinned TODO below.
3359
+ }));
3360
+ };
3361
+
3362
+ // -------------------------------------------------------------------
3363
+ // getById(): the load-bearing reverse-path closure. Mirrors the
3364
+ // pin/unpin tool's fetchFactById -> decrypt pattern (the read-back
3365
+ // reverse-path for memory_get). Returns { id, plaintext } or null.
3366
+ // -------------------------------------------------------------------
3367
+ const getById: TrNativeMemoryDeps['getById'] = async (
3368
+ id,
3369
+ ): Promise<{ id: string; plaintext: string } | null> => {
3370
+ await ensureInitialized(logger);
3371
+
3372
+ // Fail-soft on missing setup / encryption key.
3373
+ if (needsSetup || !encryptionKey || !authKeyHex) return null;
3374
+
3375
+ // The subgraph path is the canonical one; fetchFactById resolves the
3376
+ // fact by UUID and guards against owner mismatch (defense-in-depth
3377
+ // against stale IDs from another user's recall results — see
3378
+ // subgraph-search.ts fetchFactById docstring).
3379
+ if (isSubgraphMode()) {
3380
+ if (!subgraphOwner) return null;
3381
+ try {
3382
+ const result = await fetchFactById(subgraphOwner, id, authKeyHex);
3383
+ if (!result) return null;
3384
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
3385
+ if (isDigestBlob(docJson)) return null;
3386
+ const doc = readClaimFromBlob(docJson);
3387
+ return { id, plaintext: doc.text };
3388
+ } catch {
3389
+ return null;
3390
+ }
3391
+ }
3392
+
3393
+ // Server-path: apiClient doesn't expose a clean get-by-id; fall back
3394
+ // to a recall-style lookup using the id as a single trapdoor. This is
3395
+ // a degenerate path for self-hosted installs and rarely hit (the
3396
+ // native memory pipeline targets managed subgraph installs). Document
3397
+ // rather than gold-plate.
3398
+ // TODO(task 2.7b / H1 gate): wire apiClient get-by-id if/when exposed.
3399
+ if (!apiClient || !userId) return null;
3400
+ try {
3401
+ const candidates = await apiClient.search(userId, [id], 10, authKeyHex);
3402
+ const hit = candidates.find((c) => c.fact_id === id);
3403
+ if (!hit) return null;
3404
+ const docJson = decryptFromHex(hit.encrypted_blob, encryptionKey);
3405
+ if (isDigestBlob(docJson)) return null;
3406
+ const doc = readClaimFromBlob(docJson);
3407
+ return { id, plaintext: doc.text };
3408
+ } catch {
3409
+ return null;
3410
+ }
3411
+ };
3412
+
3413
+ // -------------------------------------------------------------------
3414
+ // quota + pinned: prompt-builder inputs. These drive the warning /
3415
+ // pinned-facts blocks in buildPromptSection (memory-runtime.ts).
3416
+ //
3417
+ // TODO(task 2.7b / H1 QA gate): bind these to the real paired-account
3418
+ // state. The hooks to lift are:
3419
+ // - quota: readBillingCache() in billing-cache.ts exposes
3420
+ // { free_writes_used, free_writes_limit } — when used/limit > 0.8
3421
+ // pass { usedPct }, and on a recently-observed 403 pass { denied }.
3422
+ // The billing cache is refreshed by the trajectory-poller after
3423
+ // each capture attempt; today we default to undefined so no
3424
+ // warning fires (fail-quiet — better than a false warning).
3425
+ // - pinned: there is no clean read-side `fetchPinnedFacts(owner)`
3426
+ // aggregate today. pin.ts writes pinned status into claim metadata;
3427
+ // a pinned-facts read would need either (a) a subgraph query
3428
+ // filtering on the pinned status, or (b) reuse of the hot-cache
3429
+ // pinned list. Both are extraction work — out of scope for 2.7.
3430
+ // Default to [] (no pinned block emitted).
3431
+ //
3432
+ // Returning undefined / [] here is the documented correct default. The
3433
+ // wiring helper accepts a deps object without quota/pinned, and the
3434
+ // prompt builder emits no warning / no pinned block in that case.
3435
+ // -------------------------------------------------------------------
3436
+ const quota: TrQuotaState | undefined = undefined;
3437
+ const pinned: TrPinnedFact[] | undefined = undefined;
3438
+
3439
+ return { recall, getById, quota, pinned };
3440
+ }
3441
+
3074
3442
  // ---------------------------------------------------------------------------
3075
3443
  // Plugin definition
3076
3444
  // ---------------------------------------------------------------------------
@@ -3147,68 +3515,7 @@ const plugin = {
3147
3515
  },
3148
3516
 
3149
3517
  register(api: OpenClawPluginApi) {
3150
- // ---------------------------------------------------------------
3151
- // 3.3.2-rc.1 (issue #186) — load manifest instrumentation
3152
- // ---------------------------------------------------------------
3153
- //
3154
- // Capture every `api.registerTool({name, ...})` call so we can write
3155
- // a `.loaded.json` manifest at the end of register(). Wrap the body
3156
- // in try/catch so a register-time throw produces `.error.json` for
3157
- // agent-side filesystem verification (the CLI hangs in some Docker
3158
- // setups — issue #182 — so the manifest is the canonical "did the
3159
- // plugin load?" probe).
3160
- //
3161
- // Implementation: we intercept the api.registerTool method by
3162
- // wrapping it on the api object passed in. The wrapper inspects the
3163
- // `name` field (every TR registerTool call sets it) and forwards
3164
- // verbatim. NO behavior change to the SDK call — the original method
3165
- // is invoked with original args and `this` binding.
3166
- //
3167
- // Synchronous writes ONLY (see writePluginManifest doc): the SDK
3168
- // freezes plugin registries the moment register() returns; an async
3169
- // write would race that freeze.
3170
- const _registeredToolNames: string[] = [];
3171
- const _originalRegisterTool = api.registerTool.bind(api);
3172
- // 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
3173
- // The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
3174
- // before they reach the agent's session toolset. registerTool() calls
3175
- // succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
3176
- // But tool calls never reach execute() — the pipeline discards them before
3177
- // the agent's toolset is built.
3178
- //
3179
- // Strategy: keep all registerTool() calls intact so the plugin loader can
3180
- // verify the contracts.tools declaration and load the plugin (hooks fire).
3181
- // The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
3182
- // path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
3183
- // are dead-letter but hooks (before_agent_start, agent_end, message_received,
3184
- // before_reset) still fire via the unbroken hook code path.
3185
- //
3186
- // NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
3187
- // contracts.tools declaration against registered tools at load time and
3188
- // drops the plugin (unloads it) if no tools match. Confirmed empirically:
3189
- // no-op'ing registerTool causes the gateway to log "4 plugins" instead of
3190
- // "5 plugins" after restart (plugin excluded from active set).
3191
- //
3192
- // TODO: when OC ships a fix for issue #223, restore tool-call routing
3193
- // and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
3194
- // can stay as a convenience CLI regardless.
3195
- api.registerTool = (tool: unknown, opts?: { name?: string; names?: string[] }) => {
3196
- try {
3197
- const t = tool as { name?: unknown } | null | undefined;
3198
- if (t && typeof t === 'object' && typeof t.name === 'string' && t.name.length > 0) {
3199
- _registeredToolNames.push(t.name);
3200
- }
3201
- } catch {
3202
- // Manifest is diagnostic; never let bookkeeping break tool registration.
3203
- }
3204
- _originalRegisterTool(tool, opts);
3205
- };
3206
-
3207
- // Lazily resolved inside the try below — needed by both the manifest
3208
- // write and the error path. `dist/` after build, package root in tests.
3209
- let _pluginDirForManifest: string | null = null;
3210
-
3211
- // NOTE: the body of register() below is intentionally NOT re-indented
3518
+ // NOTE: the body of register() below is intentionally NOT re-indent
3212
3519
  // under this `try` block — re-indenting would touch every line in a
3213
3520
  // 3,500-line function and obscure the actual hotfix diff. The closing
3214
3521
  // `} catch (registerErr: unknown) { ... }` is at the very end of
@@ -3218,9 +3525,11 @@ const plugin = {
3218
3525
  // RC-build detection (3.3.1-rc.3)
3219
3526
  // ---------------------------------------------------------------
3220
3527
  //
3221
- // `isRcBuild` reads the plugin's own version string. When true, the
3222
- // `totalreclaw_report_qa_bug` tool is registered at the end of this
3223
- // function stable builds never see it. The version is resolved via
3528
+ // `isRcBuild` reads the plugin's own version string. The resulting
3529
+ // `rcMode` flag is currently logged but has no gating effect after
3530
+ // Task 3.2 retired the RC-only `totalreclaw_report_qa_bug` agent tool
3531
+ // (the only former consumer). The flag is retained for the log line and
3532
+ // any future RC-gated diagnostic surface. The version is resolved via
3224
3533
  // `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
3225
3534
  let rcMode = false;
3226
3535
  // Plugin version resolved from package.json once at register time. Reused
@@ -3238,7 +3547,6 @@ const plugin = {
3238
3547
  // `require('node:url')` — undefined under bare-ESM Node, broke the
3239
3548
  // before_agent_start hook in the published rc.20 bundle (issue #124).
3240
3549
  const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
3241
- _pluginDirForManifest = pluginDir; // captured for #186 .loaded.json/.error.json
3242
3550
  pluginVersion = readPluginVersion(pluginDir);
3243
3551
  rcMode = isRcBuild(pluginVersion);
3244
3552
  if (rcMode) {
@@ -3296,10 +3604,10 @@ const plugin = {
3296
3604
 
3297
3605
  // 3.3.3-rc.1 (issue #187 — ONNX decouple): kick off a non-blocking
3298
3606
  // bundle prefetch so the ~700 MB embedder tarball starts streaming
3299
- // as soon as the gateway boots, BEFORE the user completes
3300
- // `totalreclaw_pair`. Decouples the model download from the
3301
- // pair-completion gate the previous flow imposed via
3302
- // `requireFullSetup()` -> first `generateEmbedding()` call.
3607
+ // as soon as the gateway boots, BEFORE the user completes pairing
3608
+ // (`tr pair` / the `/plugin/totalreclaw/pair/*` HTTP route). Decouples
3609
+ // the model download from the pair-completion gate the previous flow
3610
+ // imposed via `requireFullSetup()` -> first `generateEmbedding()` call.
3303
3611
  // Fire-and-forget — never awaits, never throws on failure (the next
3304
3612
  // `generateEmbedding()` call retries via the same idempotent path).
3305
3613
  // Disabled when `TOTALRECLAW_DISABLE_EMBEDDER_PREFETCH=1` (CI / tests
@@ -3353,14 +3661,16 @@ const plugin = {
3353
3661
  }
3354
3662
 
3355
3663
  // 3.3.9-rc.2 (issues #225 + #226): auto-patch openclaw.json for
3356
- // OpenClaw 2026.5.x. Two required config keys were not auto-applied
3357
- // by `openclaw plugins install` in 2026.5.x:
3664
+ // OpenClaw 2026.5.x. Required config keys not auto-applied by
3665
+ // `openclaw plugins install` in 2026.5.x:
3358
3666
  //
3359
- // 1. plugins.slots.memory = "totalreclaw"
3360
- // OpenClaw 2026.5.x introduced memory-slot exclusivitya
3361
- // memory-kind plugin MUST explicitly claim the slot or it is
3362
- // silently disabled (no error shown; `openclaw plugins inspect`
3363
- // shows "memory slot set to memory-core"). #225.
3667
+ // NOTE (rc.20, #402): patchOpenClawConfig now applies only the two
3668
+ // keys below. Retired: the memory slot (plugins.slots.memoryOpenClaw
3669
+ // 2026.6.8 claims it natively on install/enable), the installs
3670
+ // self-heal (plugins.installs native install owns it; a fabricated
3671
+ // record fails the host's schema validation), and the plugins.allow
3672
+ // self-append + plugins.bundledDiscovery="compat" pair (native install
3673
+ // manages the allowlist).
3364
3674
  //
3365
3675
  // 2. plugins.entries.totalreclaw.hooks.allowConversationAccess = true
3366
3676
  // Non-bundled plugins in 2026.5.x require this flag to receive
@@ -3379,9 +3689,9 @@ const plugin = {
3379
3689
  // openclaw.json at startup, not dynamically). We emit a warn so
3380
3690
  // the user and ops scripts know to trigger a restart.
3381
3691
  try {
3382
- // 3.3.12-rc.3: pass pluginVersion so Fix #6 can self-heal a
3383
- // stripped `plugins.installs.totalreclaw` record (and unblock
3384
- // Fix #1 which gates on installs being present).
3692
+ // pluginVersion is still passed for signature stability; as of rc.20
3693
+ // (#402) patchOpenClawConfig no longer consumes it (the Fix #6 installs
3694
+ // self-heal it fed was retired).
3385
3695
  const patchResult = patchOpenClawConfig(undefined, pluginVersion ?? undefined);
3386
3696
  if (patchResult === 'patched') {
3387
3697
  // 3.3.12-rc.6 (auto-QA finding 2026-05-09): previously we only
@@ -3416,9 +3726,7 @@ const plugin = {
3416
3726
  // pattern.
3417
3727
  api.logger.warn(
3418
3728
  'TotalReclaw: updated openclaw.json with required 2026.5.x keys ' +
3419
- '(plugins.slots.memory + hooks.allowConversationAccess + ' +
3420
- 'channels.telegram.streaming.mode + plugins.bundledDiscovery + ' +
3421
- 'plugins.allow + plugins.installs.totalreclaw self-heal). ' +
3729
+ '(hooks.allowConversationAccess + channels.telegram.streaming.mode). ' +
3422
3730
  'Auto-restarting gateway via SIGUSR1 to apply.',
3423
3731
  );
3424
3732
  setTimeout(() => {
@@ -3436,9 +3744,10 @@ const plugin = {
3436
3744
  } else if (patchResult === 'error') {
3437
3745
  api.logger.warn(
3438
3746
  'TotalReclaw: failed to auto-patch openclaw.json for OpenClaw 2026.5.x ' +
3439
- 'compatibility. If memory hooks are silently disabled, add these keys ' +
3440
- 'manually: plugins.slots.memory="totalreclaw" and ' +
3441
- 'plugins.entries.totalreclaw.hooks.allowConversationAccess=true.',
3747
+ 'compatibility. If memory hooks are silently disabled, add this key ' +
3748
+ 'manually: plugins.entries.totalreclaw.hooks.allowConversationAccess=true. ' +
3749
+ '(The memory slot is set by OpenClaw itself on plugin install/enable; ' +
3750
+ 'if the slot is wrong, run: openclaw plugins enable totalreclaw)',
3442
3751
  );
3443
3752
  }
3444
3753
  // 'unchanged' and 'skipped' are silent — no log needed.
@@ -3466,79 +3775,6 @@ const plugin = {
3466
3775
  }
3467
3776
  }
3468
3777
 
3469
- // ---------------------------------------------------------------
3470
- // 3.3.13 — auto-pair-on-load (Pop-OS hallucination fix)
3471
- // ---------------------------------------------------------------
3472
- //
3473
- // If credentials.json is missing, open a relay pair session NOW and
3474
- // write URL + PIN + sid to ~/.totalreclaw/.pair-pending.json. The
3475
- // before_agent_start hook installed below reads that file and tells
3476
- // the agent the EXACT values to surface — the agent no longer guesses.
3477
- //
3478
- // Fire-and-forget so a relay outage never blocks plugin load. The
3479
- // sentinel write happens BEFORE the hook is registered, but the WS
3480
- // listener and credentials.json write run in the background after
3481
- // register() returns.
3482
- void (async () => {
3483
- try {
3484
- const result = await maybeStartAutoPair({
3485
- credentialsPath: CREDENTIALS_PATH,
3486
- pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
3487
- onboardingStatePath: CONFIG.onboardingStatePath,
3488
- relayBaseUrl: CONFIG.pairRelayUrl,
3489
- pluginVersion: pluginVersion ?? '3.3.0',
3490
- logger: api.logger,
3491
- });
3492
- switch (result.status) {
3493
- case 'creds_exist':
3494
- // No-op — happy path; user is already set up.
3495
- break;
3496
- case 'pending_reused':
3497
- api.logger.info(
3498
- 'TotalReclaw: setup pending. Reusing existing pair URL at ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.',
3499
- );
3500
- break;
3501
- case 'started':
3502
- api.logger.info(
3503
- 'TotalReclaw: setup pending. Pair URL written to ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.',
3504
- );
3505
- break;
3506
- case 'failed':
3507
- api.logger.warn(
3508
- `TotalReclaw: setup pending. Auto-pair failed (${result.error}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`,
3509
- );
3510
- break;
3511
- }
3512
- } catch (err: unknown) {
3513
- const msg = err instanceof Error ? err.message : String(err);
3514
- api.logger.warn(
3515
- `TotalReclaw: setup pending. Auto-pair failed (${msg}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`,
3516
- );
3517
- }
3518
- })();
3519
-
3520
- // Install the before_agent_start hook that reads the sentinel and
3521
- // injects URL + PIN into the agent's context verbatim. Registered
3522
- // here so it lives alongside the other hook registrations in
3523
- // register(); the hook body itself never blocks on plugin load.
3524
- try {
3525
- installPairPendingHook(api, {
3526
- credentialsPath: CREDENTIALS_PATH,
3527
- pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
3528
- autoPairDepsFactory: () => ({
3529
- credentialsPath: CREDENTIALS_PATH,
3530
- pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
3531
- onboardingStatePath: CONFIG.onboardingStatePath,
3532
- relayBaseUrl: CONFIG.pairRelayUrl,
3533
- pluginVersion: pluginVersion ?? '3.3.0',
3534
- logger: api.logger,
3535
- }),
3536
- });
3537
- } catch (err: unknown) {
3538
- const msg = err instanceof Error ? err.message : String(err);
3539
- api.logger.warn(`TotalReclaw: failed to register pair-pending-injection hook: ${msg}`);
3540
- }
3541
-
3542
3778
  // ---------------------------------------------------------------
3543
3779
  // LLM client initialization (auto-detect provider from OpenClaw config)
3544
3780
  // ---------------------------------------------------------------
@@ -3682,6 +3918,267 @@ const plugin = {
3682
3918
  });
3683
3919
  },
3684
3920
  });
3921
+
3922
+ // ---------------------------------------------------------------
3923
+ // 3.3.13 — `openclaw totalreclaw import ...` + `upgrade`
3924
+ //
3925
+ // Phase 3.2 retired the totalreclaw_import_from / import_status /
3926
+ // import_abort / upgrade agent tools (recall is native; the rest
3927
+ // became CLI/HTTP surfaces). The handlers stayed in this file
3928
+ // (auto-resume still calls handlePluginImportFrom on gateway
3929
+ // restart) but had NO user-facing entry point — users could not
3930
+ // START a new import, only auto-resume worked. This wiring closes
3931
+ // that gap.
3932
+ //
3933
+ // Why this lives on the `openclaw totalreclaw` subcommand chain
3934
+ // (NOT the standalone `tr` CLI binary): the import handler reaches
3935
+ // module-level state (authKeyHex / encryptionKey / subgraphOwner)
3936
+ // populated by initialize(), plus storeExtractedFacts +
3937
+ // extractFacts + runSmartImportPipeline. The `tr` binary
3938
+ // (tr-cli.ts) is a standalone Node script that does NOT import
3939
+ // the plugin runtime; importing index.ts from it would pull in
3940
+ // the entire gateway runtime. The registerCli subcommand runs
3941
+ // INSIDE the gateway process, so the handlers are directly in
3942
+ // scope — same pattern as `onboard` / `status` / `pair`.
3943
+ //
3944
+ // JSON output: every subcommand accepts --json and emits a single
3945
+ // machine-parseable JSON line on stdout (agent-driven use). Plain
3946
+ // text is for direct user CLI use.
3947
+ //
3948
+ // rc.20 (#402): the import/upgrade wiring below referenced a bare
3949
+ // `tr` that was never declared in THIS callback scope —
3950
+ // registerOnboardingCli and registerPairCli each declare their own
3951
+ // LOCAL `tr`, invisible here. That undeclared reference threw
3952
+ // `ReferenceError: tr is not defined` the moment OpenClaw ran the
3953
+ // callback, killing EVERY `openclaw totalreclaw <sub>` command
3954
+ // (dead since the 3.3.13 import/upgrade restoration; shipped in
3955
+ // rc.19 + rc.20). The build is `tsc --noCheck`, so the type checker
3956
+ // never caught it. Resolve the command group the same way
3957
+ // registerPairCli does — registerOnboardingCli always created it, so
3958
+ // this find() succeeds; the guard is belt-and-braces.
3959
+ const tr = program.commands.find((c: any) => c.name() === 'totalreclaw');
3960
+ if (!tr) {
3961
+ api.logger.warn(
3962
+ 'TotalReclaw: `totalreclaw` CLI group not found after onboarding/pair registration — ' +
3963
+ 'skipping import/upgrade wiring. `openclaw totalreclaw import`/`upgrade` will be unavailable.',
3964
+ );
3965
+ return;
3966
+ }
3967
+
3968
+ const importCmd = tr.command('import')
3969
+ .description(
3970
+ 'Import memories from another tool (Mem0, MCP Memory, ChatGPT, Claude, Gemini). ' +
3971
+ 'Subcommands: `import status`, `import abort`.',
3972
+ );
3973
+
3974
+ importCmd
3975
+ .command('from', { isDefault: true })
3976
+ .description(
3977
+ 'Start an import from a source tool. Conversation sources (ChatGPT/Claude/Gemini) ' +
3978
+ 'run in the background; poll with `import status`. Pre-structured sources (Mem0/MCP) ' +
3979
+ 'store synchronously.',
3980
+ )
3981
+ .argument('<source>', 'mem0 | mcp-memory | chatgpt | claude | gemini')
3982
+ .option('--file <path>', 'Path to the source file on disk')
3983
+ .option('--content <text>', 'Inline source content (JSON/JSONL/CSV/text)')
3984
+ .option('--api-key <key>', 'API key for the source (used once, never stored)')
3985
+ .option('--source-user-id <id>', 'User/agent ID in the source system')
3986
+ .option('--api-url <url>', 'API base URL override (self-hosted instances)')
3987
+ .option('--dry-run', 'Parse + report without storing')
3988
+ .option('--resume <importId>', 'Resume a previously-started import by id')
3989
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
3990
+ .action(async (source: string, opts: {
3991
+ file?: string;
3992
+ content?: string;
3993
+ apiKey?: string;
3994
+ sourceUserId?: string;
3995
+ apiUrl?: string;
3996
+ dryRun?: boolean;
3997
+ resume?: string;
3998
+ json?: boolean;
3999
+ }) => {
4000
+ try {
4001
+ await requireFullSetup(api.logger);
4002
+ const result = await handlePluginImportFrom({
4003
+ source,
4004
+ file_path: opts.file,
4005
+ content: opts.content,
4006
+ api_key: opts.apiKey,
4007
+ source_user_id: opts.sourceUserId,
4008
+ api_url: opts.apiUrl,
4009
+ dry_run: opts.dryRun,
4010
+ resume_id: opts.resume,
4011
+ disclosure_confirmed: true,
4012
+ }, api.logger);
4013
+
4014
+ if (opts.json) {
4015
+ process.stdout.write(JSON.stringify(result) + '\n');
4016
+ } else {
4017
+ // Human-readable summary. The handler already returns a
4018
+ // `message` for chunked (background) imports; for direct
4019
+ // stores + dry runs, synthesize a short summary.
4020
+ if (result.dry_run) {
4021
+ const chunks = result.total_chunks as number | undefined;
4022
+ if (chunks !== undefined) {
4023
+ process.stdout.write(
4024
+ `Dry run: ~${result.estimated_facts} facts from ${chunks} chunks ` +
4025
+ `(~${result.estimated_minutes} min). Confirm without --dry-run to start.\n`,
4026
+ );
4027
+ } else {
4028
+ process.stdout.write(
4029
+ `Dry run: found ${result.total_found} facts. Confirm without --dry-run to import.\n`,
4030
+ );
4031
+ }
4032
+ } else if (result.import_id && result.status === 'running') {
4033
+ process.stdout.write(
4034
+ `${result.message}\nImport id: ${result.import_id}\n`,
4035
+ );
4036
+ } else {
4037
+ const stored = result.imported as number | undefined;
4038
+ const total = result.total_found as number | undefined;
4039
+ process.stdout.write(
4040
+ `Imported ${stored ?? 0}/${total ?? stored ?? 0} facts from ${source}.\n`,
4041
+ );
4042
+ }
4043
+ }
4044
+ } catch (err: unknown) {
4045
+ const message = err instanceof Error ? err.message : String(err);
4046
+ if (opts.json) {
4047
+ process.stdout.write(JSON.stringify({ success: false, error: message }) + '\n');
4048
+ } else {
4049
+ process.stderr.write(`import failed: ${message}\n`);
4050
+ }
4051
+ process.exit(1);
4052
+ }
4053
+ });
4054
+
4055
+ importCmd
4056
+ .command('status')
4057
+ .description('Check progress of a background import. Omit --id for the most recent active import.')
4058
+ .option('--id <importId>', 'Import id (from `import from`). Omit for most-recent active.')
4059
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
4060
+ .action(async (opts: { id?: string; json?: boolean }) => {
4061
+ try {
4062
+ await requireFullSetup(api.logger);
4063
+ const result = await handleImportStatus({ import_id: opts.id }, api.logger);
4064
+ if (opts.json) {
4065
+ process.stdout.write(JSON.stringify(result) + '\n');
4066
+ } else {
4067
+ const status = result.status as string | undefined;
4068
+ const stored = result.facts_stored as number | undefined;
4069
+ const batchDone = result.batch_done as number | undefined;
4070
+ const batchTotal = result.batch_total as number | undefined;
4071
+ if (status === 'no_active_import') {
4072
+ process.stdout.write('No active import. Start one with `openclaw totalreclaw import from <source>`.\n');
4073
+ } else if (status === 'running') {
4074
+ process.stdout.write(
4075
+ `Import ${result.import_id}: running — ${stored} facts stored, ` +
4076
+ `batch ${batchDone}/${batchTotal}` +
4077
+ (result.completion_iso ? `, ETA ${result.completion_iso}` : '') + '.\n',
4078
+ );
4079
+ } else {
4080
+ process.stdout.write(
4081
+ `Import ${result.import_id}: ${status} — ${stored ?? 0} facts stored.\n`,
4082
+ );
4083
+ }
4084
+ }
4085
+ } catch (err: unknown) {
4086
+ const message = err instanceof Error ? err.message : String(err);
4087
+ if (opts.json) {
4088
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
4089
+ } else {
4090
+ process.stderr.write(`import status failed: ${message}\n`);
4091
+ }
4092
+ process.exit(1);
4093
+ }
4094
+ });
4095
+
4096
+ importCmd
4097
+ .command('abort')
4098
+ .description('Cancel a running background import. Already-stored facts are kept.')
4099
+ .argument('<importId>', 'Import id to abort (from `import from` or `import status`)')
4100
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
4101
+ .action(async (importId: string, opts: { json?: boolean }) => {
4102
+ try {
4103
+ await requireFullSetup(api.logger);
4104
+ const result = await handleImportAbort({ import_id: importId }, api.logger);
4105
+ if (opts.json) {
4106
+ process.stdout.write(JSON.stringify(result) + '\n');
4107
+ } else {
4108
+ if (result.aborted) {
4109
+ process.stdout.write(
4110
+ `Import ${importId}: abort requested. ${result.facts_already_stored ?? 0} facts already stored (kept).\n`,
4111
+ );
4112
+ } else {
4113
+ process.stdout.write(
4114
+ `Import ${importId}: ${result.error ?? 'abort failed'}\n`,
4115
+ );
4116
+ }
4117
+ }
4118
+ } catch (err: unknown) {
4119
+ const message = err instanceof Error ? err.message : String(err);
4120
+ if (opts.json) {
4121
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
4122
+ } else {
4123
+ process.stderr.write(`import abort failed: ${message}\n`);
4124
+ }
4125
+ process.exit(1);
4126
+ }
4127
+ });
4128
+
4129
+ // `openclaw totalreclaw upgrade` — Stripe checkout URL for Pro.
4130
+ // Restores the retired totalreclaw_upgrade agent tool (cd21176).
4131
+ // Self-contained: POST /v1/billing/checkout → checkout_url.
4132
+ tr.command('upgrade')
4133
+ .description('Get a Stripe checkout URL to upgrade to TotalReclaw Pro (unlimited memories on Gnosis mainnet).')
4134
+ .option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
4135
+ .action(async (opts: { json?: boolean }) => {
4136
+ try {
4137
+ await requireFullSetup(api.logger);
4138
+
4139
+ if (!authKeyHex) {
4140
+ throw new Error('Auth credentials are not available. Pair first (`openclaw totalreclaw pair`).');
4141
+ }
4142
+ const walletAddr = subgraphOwner || userId || '';
4143
+ if (!walletAddr) {
4144
+ throw new Error('Wallet address not available. Ensure the plugin is fully initialized.');
4145
+ }
4146
+
4147
+ const response = await fetch(`${CONFIG.serverUrl}/v1/billing/checkout`, {
4148
+ method: 'POST',
4149
+ headers: buildRelayHeaders({
4150
+ 'Authorization': `Bearer ${authKeyHex}`,
4151
+ 'Content-Type': 'application/json',
4152
+ }),
4153
+ body: JSON.stringify({ wallet_address: walletAddr, tier: 'pro' }),
4154
+ });
4155
+
4156
+ if (!response.ok) {
4157
+ const body = await response.text().catch(() => '');
4158
+ throw new Error(`checkout session failed (HTTP ${response.status}): ${body || response.statusText}`);
4159
+ }
4160
+
4161
+ const data = await response.json() as { checkout_url?: string };
4162
+ if (!data.checkout_url) {
4163
+ throw new Error('no checkout URL returned by the relay');
4164
+ }
4165
+
4166
+ if (opts.json) {
4167
+ process.stdout.write(JSON.stringify({ checkout_url: data.checkout_url }) + '\n');
4168
+ } else {
4169
+ process.stdout.write(`Open this URL to upgrade to Pro: ${data.checkout_url}\n`);
4170
+ }
4171
+ } catch (err: unknown) {
4172
+ const message = err instanceof Error ? err.message : String(err);
4173
+ api.logger.error(`openclaw totalreclaw upgrade failed: ${message}`);
4174
+ if (opts.json) {
4175
+ process.stdout.write(JSON.stringify({ error: message }) + '\n');
4176
+ } else {
4177
+ process.stderr.write(`upgrade failed: ${humanizeError(message)}\n`);
4178
+ }
4179
+ process.exit(1);
4180
+ }
4181
+ });
3685
4182
  },
3686
4183
  { commands: ['totalreclaw'] },
3687
4184
  );
@@ -3721,6 +4218,17 @@ const plugin = {
3721
4218
  sessionsPath: CONFIG.pairSessionsPath,
3722
4219
  apiBase: '/plugin/totalreclaw/pair',
3723
4220
  logger: api.logger,
4221
+ // 3.3.14 — wire the relay URL so buildPairRoutes exposes the
4222
+ // in-process `/pair/init` route. The gateway process opens the
4223
+ // relay WebSocket directly (via openRemotePairSession from
4224
+ // pair-remote-client.ts), eliminating the 30s-subprocess-kill
4225
+ // 502 that the CLI path (tr pair) hit when OpenClaw's shell
4226
+ // tool killed the subprocess mid-pair. relayBaseUrl is sourced
4227
+ // from CONFIG.pairRelayUrl (config.ts reads it from the env
4228
+ // once, centrally) — never read from the environment inside
4229
+ // pair-http.ts (scanner-surface rule).
4230
+ relayBaseUrl: CONFIG.pairRelayUrl,
4231
+ initPairMode: 'either',
3724
4232
  validateMnemonic: (p) => validateMnemonic(p, wordlist),
3725
4233
  completePairing: async ({ mnemonic }) => {
3726
4234
  // Write credentials.json + flip state to 'active' via
@@ -3764,11 +4272,6 @@ const plugin = {
3764
4272
  credentialsCreatedAt: new Date().toISOString(),
3765
4273
  version: pluginVersion ?? '3.3.0',
3766
4274
  });
3767
- // 3.3.13 — sentinel cleanup. credentials.json is now the source
3768
- // of truth; the auto-pair-on-load .pair-pending.json sentinel
3769
- // must be removed so the before_agent_start hook stops surfacing
3770
- // the URL + PIN on subsequent turns.
3771
- deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
3772
4275
  return { state: 'active' };
3773
4276
  },
3774
4277
  });
@@ -3786,7 +4289,19 @@ const plugin = {
3786
4289
  api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
3787
4290
  api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
3788
4291
  api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
3789
- api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously');
4292
+ // 3.3.14 in-process pair trigger. The bundle exposes initPath +
4293
+ // handlers.init ONLY when relayBaseUrl is wired (always true here,
4294
+ // since CONFIG.pairRelayUrl has a built-in default). Registered
4295
+ // with auth: 'plugin' (same as the other pair routes) so the
4296
+ // agent's localhost curl reaches it without a gateway bearer
4297
+ // token. The route opens the relay WS in the gateway process →
4298
+ // survives shell-tool timeouts, retries, SIGUSR1 reloads.
4299
+ if (bundle.initPath && bundle.handlers.init) {
4300
+ api.registerHttpRoute!({ path: bundle.initPath, handler: bundle.handlers.init, auth: 'plugin' });
4301
+ api.logger.info('TotalReclaw: registered 5 QR-pairing HTTP routes synchronously (incl. in-process /pair/init)');
4302
+ } else {
4303
+ api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously (in-process /pair/init not wired — no relay URL)');
4304
+ }
3790
4305
  } else {
3791
4306
  api.logger.warn(
3792
4307
  'api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
@@ -3872,48 +4387,18 @@ const plugin = {
3872
4387
  };
3873
4388
  }
3874
4389
  if (sub === 'diag') {
3875
- // 3.3.7-rc.1 (issue #216) — diagnostic surface for the
3876
- // tool-binding-on-restart bug. Reports whether the
3877
- // plugin's register() ran in this process (boot count +
3878
- // pid + version + tool count). Non-secret: only public
3879
- // package metadata. The actual filesystem read lives in
3880
- // fs-helpers.readPluginLoadedManifest() so this file
3881
- // stays scanner-clean (whole-file rule disallows fs.read*
3882
- // co-located with `fetch` / `post` markers).
3883
- //
3884
- // Usage: `/totalreclaw diag` from chat OR `cat
3885
- // <pluginDir>/.loaded.json` from the host shell. Both
3886
- // surfaces should agree; if chat says boot=N but the
3887
- // file says boot=N+1, the chat session is stale and a
3888
- // /totalreclaw-restart is warranted.
3889
- try {
3890
- const m = _pluginDirForManifest
3891
- ? readPluginLoadedManifest(_pluginDirForManifest)
3892
- : null;
3893
- if (!m) {
3894
- return {
3895
- text:
3896
- 'TotalReclaw diag:\n' +
3897
- ` pid=${process.pid}\n` +
3898
- ` version=${pluginVersion ?? 'unknown'}\n` +
3899
- ' loaded-manifest: NOT FOUND (register() may have failed — check .error.json)',
3900
- };
3901
- }
3902
- const stalePid = typeof m.pid === 'number' && m.pid !== process.pid;
3903
- return {
3904
- text:
3905
- 'TotalReclaw diag:\n' +
3906
- ` current pid=${process.pid}\n` +
3907
- ` manifest pid=${m.pid ?? '?'}${stalePid ? ' (STALE — file from prior boot, register() did NOT run in this process)' : ''}\n` +
3908
- ` version=${m.version ?? 'unknown'}\n` +
3909
- ` boot count=${m.bootCount ?? '?'}\n` +
3910
- ` boot at=${m.bootAt ?? '?'}\n` +
3911
- ` tools registered=${m.tools?.length ?? 0}`,
3912
- };
3913
- } catch (err: unknown) {
3914
- const msg = err instanceof Error ? err.message : String(err);
3915
- return { text: `TotalReclaw diag: error reading manifest (${msg})` };
3916
- }
4390
+ // Diagnostic surface. The 3.3.7-rc.1 `.loaded.json` manifest
4391
+ // (boot count + pid + tool count) was retired in Phase 3.4 —
4392
+ // the writer was removed in 3.1 and the reader had nothing
4393
+ // current to read. `/totalreclaw diag` now reports pid + the
4394
+ // in-memory plugin version only. For richer boot history,
4395
+ // consult the gateway logs.
4396
+ return {
4397
+ text:
4398
+ 'TotalReclaw diag:\n' +
4399
+ ` pid=${process.pid}\n` +
4400
+ ` version=${pluginVersion ?? 'unknown'}\n`,
4401
+ };
3917
4402
  }
3918
4403
  return {
3919
4404
  text:
@@ -4074,2566 +4559,20 @@ const plugin = {
4074
4559
  );
4075
4560
 
4076
4561
  // ---------------------------------------------------------------
4077
- // Tool: totalreclaw_remember
4078
- // ---------------------------------------------------------------
4079
-
4080
- api.registerTool(
4081
- {
4082
- name: 'totalreclaw_remember',
4083
- label: 'Remember',
4084
- description:
4085
- 'Store a memory in the encrypted vault. Use this when the user shares important information worth remembering.',
4086
- parameters: {
4087
- type: 'object',
4088
- properties: {
4089
- text: {
4090
- type: 'string',
4091
- description: 'The memory text to store',
4092
- },
4093
- type: {
4094
- type: 'string',
4095
- // Dedup the merged enum. `preference` and `summary` appear in
4096
- // BOTH v1 (VALID_MEMORY_TYPES) and legacy v0 (LEGACY_V0_MEMORY_TYPES),
4097
- // so the naive spread produces duplicate items at ## 5 and 12
4098
- // (QA failure on 3.0.7-rc.1: ajv rejects schema with "items ##
4099
- // 5 and 12 are identical"). `new Set(...)` drops dupes while
4100
- // preserving insertion order so v1 tokens appear first in the
4101
- // enum — agents default to picking one of those.
4102
- enum: Array.from(new Set([...VALID_MEMORY_TYPES, ...LEGACY_V0_MEMORY_TYPES])),
4103
- description:
4104
- 'Memory Taxonomy v1 type: claim, preference, directive, commitment, episode, summary. ' +
4105
- 'Use "claim" for factual assertions and decisions (populate `reasoning` with the why clause). ' +
4106
- 'Use "directive" for imperative rules ("always X", "never Y"), "commitment" for future intent, ' +
4107
- 'and "episode" for notable events. Legacy v0 tokens (fact, decision, episodic, goal, context, ' +
4108
- 'rule) are silently coerced to their v1 equivalents. Default: claim.',
4109
- },
4110
- source: {
4111
- type: 'string',
4112
- enum: [...VALID_MEMORY_SOURCES],
4113
- description:
4114
- 'v1 provenance tag. "user" = user explicitly stated it, "user-inferred" = inferred from user ' +
4115
- 'signals, "assistant" = assistant-authored (downgrade unless user affirmed), "external" / ' +
4116
- '"derived" = rare. Explicit remembers default to "user".',
4117
- },
4118
- scope: {
4119
- type: 'string',
4120
- enum: [...VALID_MEMORY_SCOPES],
4121
- description:
4122
- 'v1 life-domain scope: work, personal, health, family, creative, finance, misc, unspecified. ' +
4123
- 'Default: unspecified.',
4124
- },
4125
- reasoning: {
4126
- type: 'string',
4127
- description:
4128
- 'For type=claim expressing a decision, the WHY clause ("because Y"). Max 256 chars. ' +
4129
- 'Omit for non-decision claims.',
4130
- maxLength: 256,
4131
- },
4132
- importance: {
4133
- type: 'number',
4134
- minimum: 1,
4135
- maximum: 10,
4136
- description: 'Importance score 1-10 (default: 8 for explicit remember)',
4137
- },
4138
- entities: {
4139
- type: 'array',
4140
- description:
4141
- 'Named entities this memory is about (people, projects, tools, companies, concepts, places). ' +
4142
- 'Supplying entities enables Phase 2 contradiction detection against existing facts about the same entity. ' +
4143
- 'Omit if unclear — a best-effort fallback will still store the memory.',
4144
- items: {
4145
- type: 'object',
4146
- properties: {
4147
- name: { type: 'string' },
4148
- type: {
4149
- type: 'string',
4150
- enum: ['person', 'project', 'tool', 'company', 'concept', 'place'],
4151
- },
4152
- role: { type: 'string' },
4153
- },
4154
- required: ['name', 'type'],
4155
- additionalProperties: false,
4156
- },
4157
- },
4158
- },
4159
- required: ['text'],
4160
- additionalProperties: false,
4161
- },
4162
- async execute(
4163
- _toolCallId: string,
4164
- params: {
4165
- text: string;
4166
- type?: string;
4167
- source?: string;
4168
- scope?: string;
4169
- reasoning?: string;
4170
- importance?: number;
4171
- entities?: Array<{ name: string; type: string; role?: string }>;
4172
- },
4173
- ) {
4174
- try {
4175
- await requireFullSetup(api.logger);
4176
-
4177
- // v1 taxonomy: route explicit remembers through the same canonical
4178
- // store path that auto-extraction uses (`storeExtractedFacts`). This
4179
- // emits a Memory Taxonomy v1 JSON blob, generates entity trapdoors,
4180
- // and runs through the Phase 2 contradiction-resolution pipeline.
4181
- //
4182
- // Accept legacy v0 tokens on input and coerce to v1 via
4183
- // `normalizeToV1Type` so agents that still emit the pre-v3
4184
- // taxonomy keep working.
4185
- const rawType = typeof params.type === 'string' ? params.type.toLowerCase() : 'claim';
4186
- const memoryType: MemoryType = isValidMemoryType(rawType)
4187
- ? rawType
4188
- : normalizeToV1Type(rawType);
4189
-
4190
- // Source defaults to 'user' for explicit remembers (the user is
4191
- // the author by definition). Ignored if the caller passes an
4192
- // invalid value.
4193
- const rawSource = typeof params.source === 'string' ? params.source.toLowerCase() : 'user';
4194
- const memorySource: MemorySource =
4195
- (VALID_MEMORY_SOURCES as readonly string[]).includes(rawSource)
4196
- ? (rawSource as MemorySource)
4197
- : 'user';
4198
-
4199
- const rawScope = typeof params.scope === 'string' ? params.scope.toLowerCase() : 'unspecified';
4200
- const memoryScope: MemoryScope =
4201
- (VALID_MEMORY_SCOPES as readonly string[]).includes(rawScope)
4202
- ? (rawScope as MemoryScope)
4203
- : 'unspecified';
4204
-
4205
- const reasoning =
4206
- typeof params.reasoning === 'string' && params.reasoning.length > 0
4207
- ? params.reasoning.slice(0, 256)
4208
- : undefined;
4209
-
4210
- // Explicit remember defaults to importance 8 (above auto-extraction's
4211
- // typical 6-7), so store-time dedup's shouldSupersede prefers the
4212
- // explicit call when it collides with an auto-extracted claim.
4213
- const importance = Math.max(1, Math.min(10, params.importance ?? 8));
4214
-
4215
- const validatedEntities: ExtractedEntity[] = Array.isArray(params.entities)
4216
- ? params.entities
4217
- .map((e) => parseEntity(e))
4218
- .filter((e): e is ExtractedEntity => e !== null)
4219
- : [];
4220
-
4221
- const fact: ExtractedFact = {
4222
- text: params.text.slice(0, 512),
4223
- type: memoryType,
4224
- source: memorySource,
4225
- scope: memoryScope,
4226
- reasoning,
4227
- importance,
4228
- action: 'ADD',
4229
- confidence: 1.0, // user explicitly asked to remember — highest confidence
4230
- };
4231
- if (validatedEntities.length > 0) fact.entities = validatedEntities;
4232
-
4233
- const stored = await storeExtractedFacts([fact], api.logger, 'explicit');
4234
- api.logger.info(
4235
- `totalreclaw_remember: routed to storeExtractedFacts (stored=${stored}, entities=${validatedEntities.length})`,
4236
- );
4237
-
4238
- if (stored === 0) {
4239
- // Dedup or supersession consumed the write. Treat as success from
4240
- // the user's perspective — the memory's content is already in the
4241
- // vault (possibly under a different ID).
4242
- return {
4243
- content: [
4244
- {
4245
- type: 'text',
4246
- text: 'Memory noted (matched existing content in vault).',
4247
- },
4248
- ],
4249
- };
4250
- }
4251
-
4252
- return {
4253
- content: [{ type: 'text', text: 'Memory encrypted and stored.' }],
4254
- };
4255
- } catch (err: unknown) {
4256
- const message = err instanceof Error ? err.message : String(err);
4257
- api.logger.error(`totalreclaw_remember failed: ${message}`);
4258
- return {
4259
- content: [{ type: 'text', text: `Failed to store memory: ${humanizeError(message)}` }],
4260
- };
4261
- }
4262
- },
4263
- },
4264
- { name: 'totalreclaw_remember' },
4265
- );
4266
-
4267
- // ---------------------------------------------------------------
4268
- // Tool: totalreclaw_recall
4269
- // ---------------------------------------------------------------
4270
-
4271
- api.registerTool(
4272
- {
4273
- name: 'totalreclaw_recall',
4274
- label: 'Recall',
4275
- description:
4276
- 'Search the encrypted memory vault. Returns the most relevant memories matching the query.',
4277
- parameters: {
4278
- type: 'object',
4279
- properties: {
4280
- query: {
4281
- type: 'string',
4282
- description: 'Search query text',
4283
- },
4284
- k: {
4285
- type: 'number',
4286
- minimum: 1,
4287
- maximum: 20,
4288
- description: 'Number of results to return (default: 8)',
4289
- },
4290
- },
4291
- required: ['query'],
4292
- additionalProperties: false,
4293
- },
4294
- async execute(_toolCallId: string, params: { query: string; k?: number }) {
4295
- try {
4296
- await requireFullSetup(api.logger);
4297
-
4298
- const k = Math.min(params.k ?? 8, 20);
4299
-
4300
- // 1. Generate word trapdoors (blind indices for the query).
4301
- const wordTrapdoors = generateBlindIndices(params.query);
4302
-
4303
- // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
4304
- let queryEmbedding: number[] | null = null;
4305
- let lshTrapdoors: string[] = [];
4306
- try {
4307
- queryEmbedding = await generateEmbedding(params.query, { isQuery: true });
4308
- const hasher = getLSHHasher(api.logger);
4309
- if (hasher && queryEmbedding) {
4310
- lshTrapdoors = hasher.hash(queryEmbedding);
4311
- }
4312
- } catch (err) {
4313
- const msg = err instanceof Error ? err.message : String(err);
4314
- api.logger.warn(`Recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
4315
- }
4316
-
4317
- // 3. Merge word trapdoors + LSH trapdoors.
4318
- const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
4319
-
4320
- if (allTrapdoors.length === 0) {
4321
- return {
4322
- content: [{ type: 'text', text: 'No searchable terms in query.' }],
4323
- details: { count: 0, memories: [] },
4324
- };
4325
- }
4326
-
4327
- // 4. Request more candidates than needed so we can re-rank client-side.
4328
- // 5. Decrypt candidates (text + embeddings) and build reranker input.
4329
- const rerankerCandidates: RerankerCandidate[] = [];
4330
- const metaMap = new Map<string, { metadata: Record<string, unknown>; timestamp: number }>();
4331
-
4332
- if (isSubgraphMode()) {
4333
- // --- Subgraph search path ---
4334
- const factCount = await getSubgraphFactCount(subgraphOwner || userId!, authKeyHex!);
4335
- const pool = computeCandidatePool(factCount);
4336
- let subgraphResults = await searchSubgraph(subgraphOwner || userId!, allTrapdoors, pool, authKeyHex!);
4337
-
4338
- // Always run broadened search and merge — ensures vocabulary mismatches
4339
- // (e.g., "preferences" vs "prefer") don't cause recall failures.
4340
- // The reranker handles scoring; extra cost is ~1 GraphQL query per recall.
4341
- try {
4342
- const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId!, pool, authKeyHex!);
4343
- // Merge broadened results with existing (deduplicate by ID)
4344
- const existingIds = new Set(subgraphResults.map(r => r.id));
4345
- for (const br of broadenedResults) {
4346
- if (!existingIds.has(br.id)) {
4347
- subgraphResults.push(br);
4348
- }
4349
- }
4350
- } catch { /* best-effort */ }
4351
-
4352
- for (const result of subgraphResults) {
4353
- try {
4354
- const docJson = decryptFromHex(result.encryptedBlob, encryptionKey!);
4355
- if (isDigestBlob(docJson)) continue;
4356
- const doc = readClaimFromBlob(docJson);
4357
-
4358
- let decryptedEmbedding: number[] | undefined;
4359
- if (result.encryptedEmbedding) {
4360
- try {
4361
- decryptedEmbedding = JSON.parse(
4362
- decryptFromHex(result.encryptedEmbedding, encryptionKey!),
4363
- );
4364
- } catch {
4365
- // Embedding decryption failed -- proceed without it.
4366
- }
4367
- }
4368
-
4369
- if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
4370
- try {
4371
- decryptedEmbedding = await generateEmbedding(doc.text);
4372
- } catch {
4373
- decryptedEmbedding = undefined;
4374
- }
4375
- }
4376
-
4377
- rerankerCandidates.push({
4378
- id: result.id,
4379
- text: doc.text,
4380
- embedding: decryptedEmbedding,
4381
- importance: doc.importance / 10,
4382
- createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
4383
- // Retrieval v2 Tier 1: surface v1 source so applySourceWeights
4384
- // can multiply the final RRF score by the source weight.
4385
- source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
4386
- });
4387
-
4388
- metaMap.set(result.id, {
4389
- metadata: doc.metadata ?? {},
4390
- timestamp: Date.now(),
4391
- category: doc.category,
4392
- });
4393
- } catch {
4394
- // Skip candidates we cannot decrypt.
4395
- }
4396
- }
4397
-
4398
- // Update hot cache with top results for instant auto-recall.
4399
- try {
4400
- if (!pluginHotCache && encryptionKey) {
4401
- const config = getSubgraphConfig();
4402
- pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
4403
- pluginHotCache.load();
4404
- }
4405
- if (pluginHotCache) {
4406
- const hotFacts: HotFact[] = rerankerCandidates.map((c) => {
4407
- const meta = metaMap.get(c.id);
4408
- const importance = meta?.metadata.importance
4409
- ? Math.round((meta.metadata.importance as number) * 10)
4410
- : 5;
4411
- return { id: c.id, text: c.text, importance };
4412
- });
4413
- pluginHotCache.setHotFacts(hotFacts);
4414
- pluginHotCache.setFactCount(rerankerCandidates.length);
4415
- pluginHotCache.flush();
4416
- }
4417
- } catch {
4418
- // Hot cache update is best-effort -- don't fail the recall.
4419
- }
4420
- } else {
4421
- // --- Server search path (existing behavior) ---
4422
- const factCount = await getFactCount(api.logger);
4423
- const pool = computeCandidatePool(factCount);
4424
- const candidates = await apiClient!.search(
4425
- userId!,
4426
- allTrapdoors,
4427
- pool,
4428
- authKeyHex!,
4429
- );
4430
-
4431
- for (const candidate of candidates) {
4432
- try {
4433
- const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey!);
4434
- if (isDigestBlob(docJson)) continue;
4435
- const doc = readClaimFromBlob(docJson);
4436
-
4437
- let decryptedEmbedding: number[] | undefined;
4438
- if (candidate.encrypted_embedding) {
4439
- try {
4440
- decryptedEmbedding = JSON.parse(
4441
- decryptFromHex(candidate.encrypted_embedding, encryptionKey!),
4442
- );
4443
- } catch {
4444
- // Embedding decryption failed -- proceed without it.
4445
- }
4446
- }
4447
-
4448
- if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
4449
- try {
4450
- decryptedEmbedding = await generateEmbedding(doc.text);
4451
- } catch {
4452
- decryptedEmbedding = undefined;
4453
- }
4454
- }
4455
-
4456
- rerankerCandidates.push({
4457
- id: candidate.fact_id,
4458
- text: doc.text,
4459
- embedding: decryptedEmbedding,
4460
- importance: doc.importance / 10,
4461
- createdAt: typeof candidate.timestamp === 'number'
4462
- ? candidate.timestamp / 1000
4463
- : new Date(candidate.timestamp).getTime() / 1000,
4464
- source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
4465
- });
4466
-
4467
- metaMap.set(candidate.fact_id, {
4468
- metadata: doc.metadata ?? {},
4469
- timestamp: candidate.timestamp,
4470
- category: doc.category,
4471
- });
4472
- } catch {
4473
- // Skip candidates we cannot decrypt (e.g. corrupted data).
4474
- }
4475
- }
4476
- }
4477
-
4478
- // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
4479
- const queryIntent = detectQueryIntent(params.query);
4480
- const reranked = rerank(
4481
- params.query,
4482
- queryEmbedding ?? [],
4483
- rerankerCandidates,
4484
- k,
4485
- INTENT_WEIGHTS[queryIntent],
4486
- /* applySourceWeights (Retrieval v2 Tier 1) */ true,
4487
- );
4488
-
4489
- if (reranked.length === 0) {
4490
- return {
4491
- content: [{ type: 'text', text: 'No memories found matching your query.' }],
4492
- details: { count: 0, memories: [] },
4493
- };
4494
- }
4495
-
4496
- // 6b. Relevance gate removed in rc.22 -- core's intent-weighted
4497
- // RRF + Tier 1 source weighting handles short queries via the
4498
- // BM25 component, making the rc.18 cosine + lexical-override
4499
- // band-aid (issue #116) redundant.
4500
-
4501
- // 7. Format results.
4502
- const lines = reranked.map((m, i) => {
4503
- const meta = metaMap.get(m.id);
4504
- const imp = meta?.metadata.importance
4505
- ? ` (importance: ${Math.round((meta.metadata.importance as number) * 10)}/10)`
4506
- : '';
4507
- const age = meta ? relativeTime(meta.timestamp) : '';
4508
- const typeTag = meta?.category ? `[${meta.category}] ` : '';
4509
- return `${i + 1}. ${typeTag}${m.text}${imp} -- ${age} [ID: ${m.id}]`;
4510
- });
4511
-
4512
- const formatted = lines.join('\n');
4513
-
4514
- return {
4515
- content: [{ type: 'text', text: formatted }],
4516
- details: {
4517
- count: reranked.length,
4518
- memories: reranked.map((m) => ({
4519
- factId: m.id,
4520
- text: m.text,
4521
- })),
4522
- },
4523
- };
4524
- } catch (err: unknown) {
4525
- const message = err instanceof Error ? err.message : String(err);
4526
- api.logger.error(`totalreclaw_recall failed: ${message}`);
4527
- return {
4528
- content: [{ type: 'text', text: `Failed to search memories: ${humanizeError(message)}` }],
4529
- };
4530
- }
4531
- },
4532
- },
4533
- { name: 'totalreclaw_recall' },
4534
- );
4535
-
4536
- // ---------------------------------------------------------------
4537
- // Tool: totalreclaw_forget
4538
- // ---------------------------------------------------------------
4539
-
4540
- api.registerTool(
4541
- {
4542
- name: 'totalreclaw_forget',
4543
- label: 'Forget',
4544
- description:
4545
- 'Delete a specific memory. Use when the user asks to forget, delete, or remove ' +
4546
- 'something specific (e.g. "forget that I live in Porto", "delete the memory about my old job"). ' +
4547
- 'Writes an on-chain tombstone — the delete is permanent and propagates across all devices. ' +
4548
- 'If the user names the memory in natural language instead of an ID, FIRST call ' +
4549
- '`totalreclaw_recall` with their phrase as the query, then pass the top result\'s `id` as ' +
4550
- '`factId`. Non-reversible.',
4551
- parameters: {
4552
- type: 'object',
4553
- properties: {
4554
- factId: {
4555
- type: 'string',
4556
- description:
4557
- 'The UUID of the memory to delete. Get this from a prior `totalreclaw_recall` result — ' +
4558
- 'the `memories[i].id` field. Never invent a factId; if you don\'t have one, call recall first.',
4559
- },
4560
- },
4561
- required: ['factId'],
4562
- additionalProperties: false,
4563
- },
4564
- async execute(_toolCallId: string, params: { factId: string }) {
4565
- try {
4566
- await requireFullSetup(api.logger);
4567
-
4568
- // Validate factId shape BEFORE any on-chain work. Prevents
4569
- // silent no-op when the LLM fabricates a non-UUID factId —
4570
- // the classic failure mode from 3.3.1-rc.1 QA where the
4571
- // agent replied "Done" without calling the tool at all, OR
4572
- // called the tool with a plain natural-language string.
4573
- const factId = typeof params.factId === 'string' ? params.factId.trim() : '';
4574
- if (!factId) {
4575
- return {
4576
- content: [{
4577
- type: 'text',
4578
- text:
4579
- 'Cannot forget without a memory ID. Call `totalreclaw_recall` first with ' +
4580
- 'the user\'s phrasing as the query — the top result\'s `id` field is the ' +
4581
- 'factId to pass here.',
4582
- }],
4583
- details: { deleted: false, error: 'missing-fact-id' },
4584
- };
4585
- }
4586
- // UUID-v4-ish shape check (loose — accepts any hex-dashed id).
4587
- // Prevents cases like `factId: "that I live in Porto"` from
4588
- // reaching the UserOp path and silently failing on-chain.
4589
- const looksLikeFactId = /^[0-9a-f-]{8,}$/i.test(factId);
4590
- if (!looksLikeFactId) {
4591
- api.logger.warn(
4592
- `totalreclaw_forget: rejected likely-invalid factId "${factId.slice(0, 40)}" ` +
4593
- `— expected a UUID from a prior recall result, not natural language.`,
4594
- );
4595
- return {
4596
- content: [{
4597
- type: 'text',
4598
- text:
4599
- `"${factId.slice(0, 60)}" doesn\'t look like a memory ID. Call ` +
4600
- '`totalreclaw_recall` first with the user\'s phrasing as the query, then ' +
4601
- 'pass the top result\'s `id` field (a hex UUID) as `factId`.',
4602
- }],
4603
- details: { deleted: false, error: 'invalid-fact-id' },
4604
- };
4605
- }
4606
-
4607
- if (isSubgraphMode()) {
4608
- // On-chain tombstone: write a minimal protobuf with decayScore=0
4609
- // The subgraph picks this up and sets isActive=false.
4610
- //
4611
- // 3.3.1-rc.2 fix: route through submitFactBatchOnChain with a
4612
- // single-payload batch so we share the tombstone codepath the
4613
- // pin/unpin flow uses (that flow is known-good and the QA
4614
- // confirms pin works). Also write at legacy v3 (NOT v4) so the
4615
- // subgraph handler matches the source="tombstone" + version=3
4616
- // shape the contradiction/pin tombstones use.
4617
- const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner ?? undefined };
4618
- const tombstone: FactPayload = {
4619
- id: factId,
4620
- timestamp: new Date().toISOString(),
4621
- owner: subgraphOwner || userId!,
4622
- encryptedBlob: '00', // minimal 1-byte placeholder
4623
- blindIndices: [],
4624
- decayScore: 0,
4625
- source: 'tombstone',
4626
- contentFp: '',
4627
- agentId: 'openclaw-plugin-forget',
4628
- // Deliberately NO version: field → uses the default (legacy v3).
4629
- // The pin/unpin tombstones use v3 (see pin.ts:611-621) — we
4630
- // MUST match that shape or the subgraph may not flip isActive.
4631
- };
4632
- const protobuf = encodeFactProtobuf(tombstone);
4633
- const result = await submitFactBatchOnChain([protobuf], config);
4634
- if (!result.success) {
4635
- throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
4636
- }
4637
- api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
4638
- // Read-after-write: poll the subgraph until the original fact id
4639
- // is no longer active (forget flips isActive=false). On timeout
4640
- // surface `partial: true` so the agent can explain the chain
4641
- // write succeeded but the subgraph is still propagating.
4642
- const confirm = await confirmIndexed(factId, {
4643
- expect: 'inactive',
4644
- authKeyHex: authKeyHex!,
4645
- });
4646
- return {
4647
- content: [{
4648
- type: 'text',
4649
- text: confirm.indexed
4650
- ? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
4651
- : `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
4652
- 'The subgraph indexer is still propagating the change — ' +
4653
- 'recall/export may briefly show the memory as still active.',
4654
- }],
4655
- details: {
4656
- deleted: true,
4657
- txHash: result.txHash,
4658
- factId,
4659
- ...(confirm.indexed ? {} : { partial: true }),
4660
- },
4661
- };
4662
- } else {
4663
- await apiClient!.deleteFact(factId, authKeyHex!);
4664
- return {
4665
- content: [{ type: 'text', text: `Memory ${factId} deleted` }],
4666
- details: { deleted: true, factId },
4667
- };
4668
- }
4669
- } catch (err: unknown) {
4670
- const message = err instanceof Error ? err.message : String(err);
4671
- api.logger.error(`totalreclaw_forget failed: ${message}`);
4672
- return {
4673
- content: [{ type: 'text', text: `Failed to delete memory: ${humanizeError(message)}` }],
4674
- };
4675
- }
4676
- },
4677
- },
4678
- { name: 'totalreclaw_forget' },
4679
- );
4680
-
4681
- // ---------------------------------------------------------------
4682
- // Tool: totalreclaw_export
4683
- // ---------------------------------------------------------------
4684
-
4685
- api.registerTool(
4686
- {
4687
- name: 'totalreclaw_export',
4688
- label: 'Export',
4689
- description:
4690
- 'Export all stored memories. Decrypts every memory and returns them as JSON or Markdown.',
4691
- parameters: {
4692
- type: 'object',
4693
- properties: {
4694
- format: {
4695
- type: 'string',
4696
- enum: ['json', 'markdown'],
4697
- description: 'Output format (default: json)',
4698
- },
4699
- },
4700
- additionalProperties: false,
4701
- },
4702
- async execute(_toolCallId: string, params: { format?: string }) {
4703
- try {
4704
- await requireFullSetup(api.logger);
4705
-
4706
- const format = params.format ?? 'json';
4707
-
4708
- // Paginate through all facts.
4709
- const allFacts: Array<{
4710
- id: string;
4711
- text: string;
4712
- metadata: Record<string, unknown>;
4713
- created_at: string;
4714
- }> = [];
4715
-
4716
- if (isSubgraphMode()) {
4717
- // Query subgraph for all active facts (cursor-based pagination via id_gt)
4718
- const config = getSubgraphConfig();
4719
- const relayUrl = config.relayUrl;
4720
- const PAGE_SIZE = 1000;
4721
- let lastId = '';
4722
- const owner = subgraphOwner || userId || '';
4723
- console.error(`[TotalReclaw Export] owner=${owner} subgraphOwner=${subgraphOwner} userId=${userId} relayUrl=${relayUrl} authKey=${authKeyHex ? authKeyHex.slice(0, 8) + '...' : 'MISSING'} isSubgraph=${isSubgraphMode()}`);
4724
-
4725
- while (true) {
4726
- const hasLastId = lastId !== '';
4727
- const query = hasLastId
4728
- ? `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}}`
4729
- : `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`;
4730
- const variables: Record<string, unknown> = hasLastId
4731
- ? { owner, first: PAGE_SIZE, lastId }
4732
- : { owner, first: PAGE_SIZE };
4733
-
4734
- const res = await fetch(`${relayUrl}/v1/subgraph`, {
4735
- method: 'POST',
4736
- headers: buildRelayHeaders({
4737
- 'Content-Type': 'application/json',
4738
- ...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
4739
- }),
4740
- body: JSON.stringify({ query, variables }),
4741
- });
4742
-
4743
- const json = (await res.json()) as {
4744
- data?: { facts?: Array<{ id: string; encryptedBlob: string; source: string; agentId: string; timestamp: string; sequenceId: string }> };
4745
- error?: string;
4746
- errors?: Array<{ message: string }>;
4747
- };
4748
- // Surface relay/subgraph errors instead of silently returning empty
4749
- if (json.error || json.errors) {
4750
- const errMsg = json.error || json.errors?.map(e => e.message).join('; ') || 'Unknown error';
4751
- api.logger.error(`Export subgraph query failed: ${errMsg} (owner=${owner}, status=${res.status})`);
4752
- return {
4753
- content: [{ type: 'text', text: `Export failed: ${errMsg}` }],
4754
- };
4755
- }
4756
- const facts = json?.data?.facts || [];
4757
- if (facts.length === 0) break;
4758
-
4759
- for (const fact of facts) {
4760
- try {
4761
- let hexBlob = fact.encryptedBlob;
4762
- if (hexBlob.startsWith('0x')) hexBlob = hexBlob.slice(2);
4763
- const docJson = decryptFromHex(hexBlob, encryptionKey!);
4764
- if (isDigestBlob(docJson)) continue;
4765
- const doc = readClaimFromBlob(docJson);
4766
- allFacts.push({
4767
- id: fact.id,
4768
- text: doc.text,
4769
- metadata: doc.metadata,
4770
- created_at: new Date(parseInt(fact.timestamp) * 1000).toISOString(),
4771
- });
4772
- } catch {
4773
- // Skip facts we cannot decrypt
4774
- }
4775
- }
4776
-
4777
- if (facts.length < PAGE_SIZE) break;
4778
- lastId = facts[facts.length - 1].id;
4779
- }
4780
- } else {
4781
- // HTTP server mode — paginate through PostgreSQL facts
4782
- let cursor: string | undefined;
4783
- let hasMore = true;
4784
-
4785
- while (hasMore) {
4786
- const page = await apiClient!.exportFacts(authKeyHex!, 1000, cursor);
4787
-
4788
- for (const fact of page.facts) {
4789
- try {
4790
- const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey!);
4791
- if (isDigestBlob(docJson)) continue;
4792
- const doc = readClaimFromBlob(docJson);
4793
- allFacts.push({
4794
- id: fact.id,
4795
- text: doc.text,
4796
- metadata: doc.metadata,
4797
- created_at: fact.created_at,
4798
- });
4799
- } catch {
4800
- // Skip facts we cannot decrypt.
4801
- }
4802
- }
4803
-
4804
- cursor = page.cursor ?? undefined;
4805
- hasMore = page.has_more;
4806
- }
4807
- }
4808
-
4809
- // Format output.
4810
- let formatted: string;
4811
-
4812
- if (format === 'markdown') {
4813
- if (allFacts.length === 0) {
4814
- formatted = '*No memories stored.*';
4815
- } else {
4816
- const lines = allFacts.map((f, i) => {
4817
- const meta = f.metadata;
4818
- const type = (meta.type as string) ?? 'fact';
4819
- const imp = meta.importance
4820
- ? ` (importance: ${Math.round((meta.importance as number) * 10)}/10)`
4821
- : '';
4822
- return `${i + 1}. **[${type}]** ${f.text}${imp} \n _ID: ${f.id} | Created: ${f.created_at}_`;
4823
- });
4824
- formatted = `# Exported Memories (${allFacts.length})\n\n${lines.join('\n')}`;
4825
- }
4826
- } else {
4827
- formatted = JSON.stringify(allFacts, null, 2);
4828
- }
4829
-
4830
- return {
4831
- content: [{ type: 'text', text: formatted }],
4832
- details: { count: allFacts.length },
4833
- };
4834
- } catch (err: unknown) {
4835
- const message = err instanceof Error ? err.message : String(err);
4836
- api.logger.error(`totalreclaw_export failed: ${message}`);
4837
- return {
4838
- content: [{ type: 'text', text: `Failed to export memories: ${humanizeError(message)}` }],
4839
- };
4840
- }
4841
- },
4842
- },
4843
- { name: 'totalreclaw_export' },
4844
- );
4845
-
4846
- // ---------------------------------------------------------------
4847
- // Tool: totalreclaw_status
4848
- // ---------------------------------------------------------------
4849
-
4850
- api.registerTool(
4851
- {
4852
- name: 'totalreclaw_status',
4853
- label: 'Status',
4854
- description:
4855
- 'Check TotalReclaw billing and subscription status — tier, writes used, reset date.',
4856
- parameters: {
4857
- type: 'object',
4858
- properties: {},
4859
- additionalProperties: false,
4860
- },
4861
- async execute() {
4862
- try {
4863
- await requireFullSetup(api.logger);
4864
-
4865
- if (!authKeyHex) {
4866
- return {
4867
- content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
4868
- };
4869
- }
4870
-
4871
- const serverUrl = CONFIG.serverUrl;
4872
- const walletAddr = subgraphOwner || userId || '';
4873
- const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
4874
- method: 'GET',
4875
- headers: buildRelayHeaders({
4876
- 'Authorization': `Bearer ${authKeyHex}`,
4877
- 'Accept': 'application/json',
4878
- }),
4879
- });
4880
-
4881
- if (!response.ok) {
4882
- const body = await response.text().catch(() => '');
4883
- return {
4884
- content: [{ type: 'text', text: `Failed to fetch billing status (HTTP ${response.status}): ${body || response.statusText}` }],
4885
- };
4886
- }
4887
-
4888
- const data = await response.json() as Record<string, unknown>;
4889
- const tier = (data.tier as string) || 'free';
4890
- const freeWritesUsed = (data.free_writes_used as number) ?? 0;
4891
- const freeWritesLimit = (data.free_writes_limit as number) ?? 0;
4892
- const freeWritesResetAt = data.free_writes_reset_at as string | undefined;
4893
-
4894
- // Update billing cache on success.
4895
- writeBillingCache({
4896
- tier,
4897
- free_writes_used: freeWritesUsed,
4898
- free_writes_limit: freeWritesLimit,
4899
- features: data.features as BillingCache['features'] | undefined,
4900
- checked_at: Date.now(),
4901
- });
4902
-
4903
- // 3.3.1 (internal#130) — surface the Smart Account / scope
4904
- // address so the user can do subgraph queries, BaseScan
4905
- // lookups, and cross-client portability checks BEFORE any
4906
- // chain write completes. Resolution priority:
4907
- // 1. In-memory `subgraphOwner` (already derived earlier).
4908
- // 2. credentials.json `scope_address` (persisted at pair).
4909
- // 3. Lazy derive now from the loaded mnemonic + cache it
4910
- // back to credentials.json so the next call is free.
4911
- let scopeAddress: string | undefined =
4912
- subgraphOwner ?? undefined;
4913
- if (!scopeAddress) {
4914
- try {
4915
- const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
4916
- if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
4917
- scopeAddress = credsCache.scope_address;
4918
- } else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
4919
- scopeAddress = await deriveSmartAccountAddress(
4920
- credsCache.mnemonic,
4921
- CONFIG.chainId,
4922
- );
4923
- if (scopeAddress) {
4924
- writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
4925
- }
4926
- }
4927
- } catch (deriveErr) {
4928
- api.logger.warn(
4929
- `totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
4930
- );
4931
- }
4932
- }
4933
-
4934
- const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
4935
- const lines: string[] = [
4936
- `Tier: ${tierLabel}`,
4937
- `Writes: ${freeWritesUsed}/${freeWritesLimit} used this month`,
4938
- ];
4939
- if (freeWritesResetAt) {
4940
- lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
4941
- }
4942
- if (scopeAddress) {
4943
- lines.push(`Smart Account: ${scopeAddress}`);
4944
- }
4945
- if (tier !== 'pro') {
4946
- lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
4947
- }
4948
-
4949
- return {
4950
- content: [{ type: 'text', text: lines.join('\n') }],
4951
- details: {
4952
- tier,
4953
- free_writes_used: freeWritesUsed,
4954
- free_writes_limit: freeWritesLimit,
4955
- scope_address: scopeAddress,
4956
- },
4957
- };
4958
- } catch (err: unknown) {
4959
- const message = err instanceof Error ? err.message : String(err);
4960
- api.logger.error(`totalreclaw_status failed: ${message}`);
4961
- return {
4962
- content: [{ type: 'text', text: `Failed to check status: ${humanizeError(message)}` }],
4963
- };
4964
- }
4965
- },
4966
- },
4967
- { name: 'totalreclaw_status' },
4968
- );
4969
-
4970
- // ---------------------------------------------------------------
4971
- // Tool: totalreclaw_preload_embedder (3.3.3-rc.1 — issue #187)
4972
- // ---------------------------------------------------------------
4973
- //
4974
- // Decouples the ~700 MB embedder bundle download from the pair-
4975
- // completion gate. The agent can call this BEFORE
4976
- // `totalreclaw_pair` to pre-flight disk space, kick off the
4977
- // download, and surface the completion state. The non-blocking
4978
- // prefetch in `register()` already starts the download
4979
- // unconditionally; this tool is an explicit on-demand hook for
4980
- // agents that want to confirm completion (or trigger a retry on
4981
- // network failure) without first completing pair.
4982
- //
4983
- // Behavior:
4984
- // - Disk-space pre-flight: refuses if free disk < 500 MB on the
4985
- // embedder cache mount. Surfaces the path + free bytes so the
4986
- // user can clear space.
4987
- // - Triggers `prefetchEmbedderBundle()` (idempotent — cache hit
4988
- // returns immediately).
4989
- // - Returns a structured success message with status:
4990
- // `cache_hit | fetched | failed`.
4991
- //
4992
- // Phrase-safety: this tool does NOT touch credentials.json,
4993
- // mnemonics, or keys. It only touches `~/.totalreclaw/embedder/`
4994
- // and the GitHub Releases CDN.
4995
-
4996
- api.registerTool(
4997
- {
4998
- name: 'totalreclaw_preload_embedder',
4999
- label: 'Preload Embedder',
5000
- description:
5001
- '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/.',
5002
- parameters: {
5003
- type: 'object',
5004
- properties: {},
5005
- additionalProperties: false,
5006
- },
5007
- async execute() {
5008
- try {
5009
- // Disk-space pre-flight: refuse if < 500 MB free on the
5010
- // embedder cache mount. Best-effort — if statfs fails, we
5011
- // proceed without the pre-flight rather than blocking.
5012
- const fsModule = await import('node:fs');
5013
- const cacheRoot = CONFIG.embedderCachePath;
5014
- const REQUIRED_BYTES = 500 * 1024 * 1024;
5015
- try {
5016
- // Find the deepest existing parent so statfs has a real
5017
- // mount to measure (loadEmbedder will mkdir under it
5018
- // anyway).
5019
- let probeDir = cacheRoot;
5020
- while (true) {
5021
- try {
5022
- fsModule.statSync(probeDir);
5023
- break;
5024
- } catch {
5025
- const parent = nodePath.dirname(probeDir);
5026
- if (parent === probeDir) break;
5027
- probeDir = parent;
5028
- }
5029
- }
5030
- const stats = (fsModule as unknown as {
5031
- statfsSync?: (p: string) => { bavail: bigint | number; bsize: bigint | number };
5032
- }).statfsSync?.(probeDir);
5033
- if (stats) {
5034
- const bavail = typeof stats.bavail === 'bigint' ? Number(stats.bavail) : stats.bavail;
5035
- const bsize = typeof stats.bsize === 'bigint' ? Number(stats.bsize) : stats.bsize;
5036
- const freeBytes = bavail * bsize;
5037
- if (freeBytes > 0 && freeBytes < REQUIRED_BYTES) {
5038
- const freeMb = Math.round(freeBytes / (1024 * 1024));
5039
- return {
5040
- content: [{
5041
- type: 'text',
5042
- text: `Insufficient free disk space for embedder bundle. Required: 500 MB. Available at ${probeDir}: ${freeMb} MB. Free up space and retry.`,
5043
- }],
5044
- details: { status: 'failed', reason: 'disk_space', free_mb: freeMb, required_mb: 500, cache_root: cacheRoot },
5045
- };
5046
- }
5047
- }
5048
- } catch {
5049
- // statfs probe failed — surface a soft warning in logs
5050
- // but proceed with the download anyway.
5051
- api.logger.info('totalreclaw_preload_embedder: disk-space probe unavailable, proceeding without pre-flight');
5052
- }
5053
-
5054
- // Trigger the prefetch. This is idempotent (cache hit returns
5055
- // immediately) so it's safe to invoke even when the
5056
- // background prefetch from register() already completed.
5057
- const result = await prefetchEmbedderBundle({
5058
- log: (msg) => api.logger.info(msg),
5059
- });
5060
- const human =
5061
- result === 'fetched'
5062
- ? `Embedder bundle downloaded and cached at ${cacheRoot}. Subsequent embedding calls run in-memory.`
5063
- : `Embedder bundle already cached at ${cacheRoot} — no download needed.`;
5064
- return {
5065
- content: [{ type: 'text', text: human }],
5066
- details: { status: result, cache_root: cacheRoot },
5067
- };
5068
- } catch (err: unknown) {
5069
- const message = err instanceof Error ? err.message : String(err);
5070
- api.logger.error(`totalreclaw_preload_embedder failed: ${message}`);
5071
- return {
5072
- content: [{
5073
- type: 'text',
5074
- text: `Embedder bundle preload failed: ${humanizeError(message)}. The plugin will retry on first embedding call.`,
5075
- }],
5076
- details: { status: 'failed', reason: 'fetch_error', message },
5077
- };
5078
- }
5079
- },
5080
- },
5081
- { name: 'totalreclaw_preload_embedder' },
5082
- );
5083
-
5084
- // ---------------------------------------------------------------
5085
- // Tool: totalreclaw_consolidate
5086
- // ---------------------------------------------------------------
5087
-
5088
- api.registerTool(
5089
- {
5090
- name: 'totalreclaw_consolidate',
5091
- label: 'Consolidate',
5092
- description:
5093
- 'Deduplicate and merge related memories. Self-hosted mode only.',
5094
- parameters: {
5095
- type: 'object',
5096
- properties: {
5097
- dry_run: {
5098
- type: 'boolean',
5099
- description: 'Preview only (default: false)',
5100
- },
5101
- },
5102
- additionalProperties: false,
5103
- },
5104
- async execute(_toolCallId: string, params: { dry_run?: boolean }) {
5105
- try {
5106
- await requireFullSetup(api.logger);
5107
-
5108
- const dryRun = params.dry_run ?? false;
5109
-
5110
- // Consolidation is only available in centralized (HTTP server) mode.
5111
- if (isSubgraphMode()) {
5112
- return {
5113
- content: [{ type: 'text', text: 'Consolidation is currently only available in centralized mode.' }],
5114
- };
5115
- }
5116
-
5117
- if (!apiClient || !authKeyHex || !encryptionKey) {
5118
- return {
5119
- content: [{ type: 'text', text: 'Plugin not fully initialized. Cannot consolidate.' }],
5120
- };
5121
- }
5122
-
5123
- // 1. Export all facts (paginated, max 10 pages of 1000).
5124
- const allDecrypted: DecryptedCandidate[] = [];
5125
- let cursor: string | undefined;
5126
- let hasMore = true;
5127
- let pageCount = 0;
5128
- const MAX_PAGES = 10;
5129
-
5130
- while (hasMore && pageCount < MAX_PAGES) {
5131
- const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
5132
-
5133
- for (const fact of page.facts) {
5134
- try {
5135
- const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
5136
- if (isDigestBlob(docJson)) continue;
5137
- const doc = readClaimFromBlob(docJson);
5138
-
5139
- let embedding: number[] | null = null;
5140
- try {
5141
- embedding = await generateEmbedding(doc.text);
5142
- } catch { /* skip — fact will not be clustered */ }
5143
-
5144
- allDecrypted.push({
5145
- id: fact.id,
5146
- text: doc.text,
5147
- embedding,
5148
- importance: doc.importance,
5149
- decayScore: fact.decay_score,
5150
- createdAt: new Date(fact.created_at).getTime(),
5151
- version: fact.version,
5152
- });
5153
- } catch {
5154
- // Skip undecryptable facts.
5155
- }
5156
- }
5157
-
5158
- cursor = page.cursor ?? undefined;
5159
- hasMore = page.has_more;
5160
- pageCount++;
5161
- }
5162
-
5163
- if (allDecrypted.length === 0) {
5164
- return {
5165
- content: [{ type: 'text', text: 'No memories found to consolidate.' }],
5166
- };
5167
- }
5168
-
5169
- // 2. Cluster by cosine similarity.
5170
- const clusters = clusterFacts(allDecrypted, getConsolidationThreshold());
5171
-
5172
- if (clusters.length === 0) {
5173
- return {
5174
- content: [{ type: 'text', text: `Scanned ${allDecrypted.length} memories — no near-duplicates found.` }],
5175
- };
5176
- }
5177
-
5178
- // 3. Build report.
5179
- const totalDuplicates = clusters.reduce((sum, c) => sum + c.duplicates.length, 0);
5180
- const reportLines: string[] = [
5181
- `Scanned ${allDecrypted.length} memories.`,
5182
- `Found ${clusters.length} cluster(s) with ${totalDuplicates} duplicate(s).`,
5183
- '',
5184
- ];
5185
-
5186
- const displayClusters = clusters.slice(0, 10);
5187
- for (let i = 0; i < displayClusters.length; i++) {
5188
- const cluster = displayClusters[i];
5189
- reportLines.push(`Cluster ${i + 1}: KEEP "${cluster.representative.text.slice(0, 80)}…"`);
5190
- for (const dup of cluster.duplicates) {
5191
- reportLines.push(` - REMOVE "${dup.text.slice(0, 80)}…" (ID: ${dup.id})`);
5192
- }
5193
- }
5194
- if (clusters.length > 10) {
5195
- reportLines.push(`... and ${clusters.length - 10} more cluster(s).`);
5196
- }
5197
-
5198
- // 4. If not dry_run, batch-delete duplicates.
5199
- if (!dryRun) {
5200
- const idsToDelete = clusters.flatMap((c) => c.duplicates.map((d) => d.id));
5201
- const BATCH_SIZE = 500;
5202
- let totalDeleted = 0;
5203
-
5204
- for (let i = 0; i < idsToDelete.length; i += BATCH_SIZE) {
5205
- const batch = idsToDelete.slice(i, i + BATCH_SIZE);
5206
- const deleted = await apiClient.batchDelete(batch, authKeyHex);
5207
- totalDeleted += deleted;
5208
- }
5209
-
5210
- reportLines.push('');
5211
- reportLines.push(`Deleted ${totalDeleted} duplicate memories.`);
5212
- } else {
5213
- reportLines.push('');
5214
- reportLines.push('DRY RUN — no memories were deleted. Run without dry_run to apply.');
5215
- }
5216
-
5217
- return {
5218
- content: [{ type: 'text', text: reportLines.join('\n') }],
5219
- details: {
5220
- scanned: allDecrypted.length,
5221
- clusters: clusters.length,
5222
- duplicates: totalDuplicates,
5223
- dry_run: dryRun,
5224
- },
5225
- };
5226
- } catch (err: unknown) {
5227
- const message = err instanceof Error ? err.message : String(err);
5228
- api.logger.error(`totalreclaw_consolidate failed: ${message}`);
5229
- return {
5230
- content: [{ type: 'text', text: `Failed to consolidate memories: ${humanizeError(message)}` }],
5231
- };
5232
- }
5233
- },
5234
- },
5235
- { name: 'totalreclaw_consolidate' },
5236
- );
5237
-
5238
- // ---------------------------------------------------------------
5239
- // Helper: build PinOpDeps bound to the live plugin state
5240
- // ---------------------------------------------------------------
5241
- // Wires the pure pin/unpin operation to the managed-service transport +
5242
- // crypto layer. Mirrors MCP's buildPinDepsFromState and Python's
5243
- // _change_claim_status argument plumbing.
5244
- const buildPinDeps = (): PinOpDeps => {
5245
- const owner = subgraphOwner || userId || '';
5246
- const config = {
5247
- ...getSubgraphConfig(),
5248
- authKeyHex: authKeyHex!,
5249
- walletAddress: subgraphOwner ?? undefined,
5250
- };
5251
- return {
5252
- owner,
5253
- sourceAgent: 'openclaw-plugin',
5254
- fetchFactById: (factId: string) => fetchFactById(owner, factId, authKeyHex!),
5255
- decryptBlob: (hex: string) => decryptFromHex(hex, encryptionKey!),
5256
- encryptBlob: (plaintext: string) => encryptToHex(plaintext, encryptionKey!),
5257
- submitBatch: async (payloads: Buffer[]) => {
5258
- const result = await submitFactBatchOnChain(payloads, config);
5259
- return { txHash: result.txHash, success: result.success };
5260
- },
5261
- generateIndices: async (text: string, entityNames: string[]) => {
5262
- if (!text) return { blindIndices: [] };
5263
- const wordIndices = generateBlindIndices(text);
5264
- let lshIndices: string[] = [];
5265
- let encryptedEmbedding: string | undefined;
5266
- try {
5267
- const embedding = await generateEmbedding(text);
5268
- const hasher = getLSHHasher(api.logger);
5269
- if (hasher) lshIndices = hasher.hash(embedding);
5270
- encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey!);
5271
- } catch {
5272
- // Best-effort: word + entity trapdoors alone still surface the claim.
5273
- }
5274
- const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
5275
- return {
5276
- blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
5277
- encryptedEmbedding,
5278
- };
5279
- },
5280
- };
5281
- };
5282
-
5283
- // ---------------------------------------------------------------
5284
- // Tool: totalreclaw_pin
5285
- // ---------------------------------------------------------------
5286
-
5287
- api.registerTool(
5288
- {
5289
- name: 'totalreclaw_pin',
5290
- label: 'Pin',
5291
- description:
5292
- 'Pin a memory so the auto-resolution engine will never override or supersede it. ' +
5293
- "Use when the user explicitly confirms a claim is still valid after you or another agent " +
5294
- "tried to retract/contradict it (e.g. 'wait, I still use Vim sometimes'). " +
5295
- 'Takes fact_id (from a prior recall result). Pinning is idempotent — pinning an already-pinned ' +
5296
- 'claim is a no-op. Cross-device: the pin propagates via the on-chain supersession chain.',
5297
- parameters: {
5298
- type: 'object',
5299
- properties: {
5300
- fact_id: {
5301
- type: 'string',
5302
- description: 'The ID of the fact to pin (from a totalreclaw_recall result).',
5303
- },
5304
- reason: {
5305
- type: 'string',
5306
- description: 'Optional human-readable reason for pinning (logged locally for tuning).',
5307
- },
5308
- },
5309
- required: ['fact_id'],
5310
- additionalProperties: false,
5311
- },
5312
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5313
- try {
5314
- await requireFullSetup(api.logger);
5315
- if (!isSubgraphMode()) {
5316
- return {
5317
- content: [{
5318
- type: 'text',
5319
- text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
5320
- }],
5321
- };
5322
- }
5323
- const validation = validatePinArgs(params);
5324
- if (!validation.ok) {
5325
- return { content: [{ type: 'text', text: validation.error }] };
5326
- }
5327
- const deps = buildPinDeps();
5328
- const result = await executePinOperation(validation.factId, 'pinned', deps, validation.reason);
5329
- if (result.success && result.idempotent) {
5330
- api.logger.info(`totalreclaw_pin: ${result.fact_id} already pinned (no-op)`);
5331
- return {
5332
- content: [{ type: 'text', text: `Memory ${result.fact_id} is already pinned.` }],
5333
- details: result,
5334
- };
5335
- }
5336
- if (result.success) {
5337
- api.logger.info(`totalreclaw_pin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
5338
- return {
5339
- content: [{
5340
- type: 'text',
5341
- text: `Pinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
5342
- }],
5343
- details: result,
5344
- };
5345
- }
5346
- api.logger.error(`totalreclaw_pin failed: ${result.error}`);
5347
- return {
5348
- content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
5349
- details: result,
5350
- };
5351
- } catch (err: unknown) {
5352
- const message = err instanceof Error ? err.message : String(err);
5353
- api.logger.error(`totalreclaw_pin failed: ${message}`);
5354
- return {
5355
- content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(message)}` }],
5356
- };
5357
- }
5358
- },
5359
- },
5360
- { name: 'totalreclaw_pin' },
5361
- );
5362
-
5363
- // ---------------------------------------------------------------
5364
- // Tool: totalreclaw_unpin
5365
- // ---------------------------------------------------------------
5366
-
5367
- api.registerTool(
5368
- {
5369
- name: 'totalreclaw_unpin',
5370
- label: 'Unpin',
5371
- description:
5372
- 'Remove the pin from a previously pinned memory, returning it to active status so the ' +
5373
- 'auto-resolution engine can supersede or retract it again. Takes fact_id. Idempotent — ' +
5374
- 'unpinning a non-pinned claim is a no-op.',
5375
- parameters: {
5376
- type: 'object',
5377
- properties: {
5378
- fact_id: {
5379
- type: 'string',
5380
- description: 'The ID of the fact to unpin (from a totalreclaw_recall result).',
5381
- },
5382
- },
5383
- required: ['fact_id'],
5384
- additionalProperties: false,
5385
- },
5386
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5387
- try {
5388
- await requireFullSetup(api.logger);
5389
- if (!isSubgraphMode()) {
5390
- return {
5391
- content: [{
5392
- type: 'text',
5393
- text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
5394
- }],
5395
- };
5396
- }
5397
- const validation = validatePinArgs(params);
5398
- if (!validation.ok) {
5399
- return { content: [{ type: 'text', text: validation.error }] };
5400
- }
5401
- const deps = buildPinDeps();
5402
- const result = await executePinOperation(validation.factId, 'active', deps);
5403
- if (result.success && result.idempotent) {
5404
- api.logger.info(`totalreclaw_unpin: ${result.fact_id} already active (no-op)`);
5405
- return {
5406
- content: [{ type: 'text', text: `Memory ${result.fact_id} is not pinned.` }],
5407
- details: result,
5408
- };
5409
- }
5410
- if (result.success) {
5411
- api.logger.info(`totalreclaw_unpin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
5412
- return {
5413
- content: [{
5414
- type: 'text',
5415
- text: `Unpinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
5416
- }],
5417
- details: result,
5418
- };
5419
- }
5420
- api.logger.error(`totalreclaw_unpin failed: ${result.error}`);
5421
- return {
5422
- content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
5423
- details: result,
5424
- };
5425
- } catch (err: unknown) {
5426
- const message = err instanceof Error ? err.message : String(err);
5427
- api.logger.error(`totalreclaw_unpin failed: ${message}`);
5428
- return {
5429
- content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(message)}` }],
5430
- };
5431
- }
5432
- },
5433
- },
5434
- { name: 'totalreclaw_unpin' },
5435
- );
5436
-
5437
- // ---------------------------------------------------------------
5438
- // Shared deps for retype + set_scope (same shape as pin deps).
5439
- // Built lazily so the closure captures the current encryption key /
5440
- // subgraph owner at call time rather than at register() time.
5441
- // ---------------------------------------------------------------
5442
- const buildRetypeSetScopeDeps = (): RetypeSetScopeDeps => {
5443
- const owner = subgraphOwner || userId || '';
5444
- const config = {
5445
- ...getSubgraphConfig(),
5446
- authKeyHex: authKeyHex!,
5447
- walletAddress: subgraphOwner ?? undefined,
5448
- };
5449
- return {
5450
- owner,
5451
- sourceAgent: 'openclaw-plugin',
5452
- fetchFactById: (factId: string) => fetchFactById(owner, factId, authKeyHex!),
5453
- decryptBlob: (hex: string) => decryptFromHex(hex, encryptionKey!),
5454
- encryptBlob: (plaintext: string) => encryptToHex(plaintext, encryptionKey!),
5455
- submitBatch: async (payloads: Buffer[]) => {
5456
- const result = await submitFactBatchOnChain(payloads, config);
5457
- return { txHash: result.txHash, success: result.success };
5458
- },
5459
- generateIndices: async (text: string, entityNames: string[]) => {
5460
- if (!text) return { blindIndices: [] };
5461
- const wordIndices = generateBlindIndices(text);
5462
- let lshIndices: string[] = [];
5463
- let encryptedEmbedding: string | undefined;
5464
- try {
5465
- const embedding = await generateEmbedding(text);
5466
- const hasher = getLSHHasher(api.logger);
5467
- if (hasher) lshIndices = hasher.hash(embedding);
5468
- encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey!);
5469
- } catch {
5470
- // Best-effort: word + entity trapdoors alone still surface the claim.
5471
- }
5472
- const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
5473
- return {
5474
- blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
5475
- encryptedEmbedding,
5476
- };
5477
- },
5478
- };
5479
- };
5480
-
5481
- // ---------------------------------------------------------------
5482
- // Tool: totalreclaw_retype (3.3.1-rc.2 — agent-facing taxonomy edit)
5483
- // ---------------------------------------------------------------
5484
-
5485
- api.registerTool(
5486
- {
5487
- name: 'totalreclaw_retype',
5488
- label: 'Retype',
5489
- description:
5490
- 'Reclassify an existing memory from one taxonomy type to another (claim / preference / ' +
5491
- 'directive / commitment / episode / summary). Use when the user corrects a memory\'s ' +
5492
- 'category — e.g. "that\'s actually a preference, not a fact" or "file this as a ' +
5493
- 'commitment, not a claim". Writes a new v1.1 blob with the updated type and tombstones ' +
5494
- 'the old fact on-chain.\n\n' +
5495
- 'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
5496
- 'find the fact_id, then pass it here with the new type.',
5497
- parameters: {
5498
- type: 'object',
5499
- properties: {
5500
- fact_id: {
5501
- type: 'string',
5502
- description:
5503
- 'The UUID of the memory to reclassify. Get this from a prior ' +
5504
- '`totalreclaw_recall` result.',
5505
- },
5506
- new_type: {
5507
- type: 'string',
5508
- enum: ['claim', 'preference', 'directive', 'commitment', 'episode', 'summary'],
5509
- description:
5510
- 'The new taxonomy type. claim=factual statement, preference=opinion/like/dislike, ' +
5511
- 'directive=instruction, commitment=promise/plan, episode=event, summary=aggregate.',
5512
- },
5513
- },
5514
- required: ['fact_id', 'new_type'],
5515
- additionalProperties: false,
5516
- },
5517
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5518
- try {
5519
- await requireFullSetup(api.logger);
5520
- if (!isSubgraphMode()) {
5521
- return {
5522
- content: [{
5523
- type: 'text',
5524
- text: 'Retype is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
5525
- }],
5526
- };
5527
- }
5528
- const validation = validateRetypeArgs(params);
5529
- if (!validation.ok) {
5530
- return { content: [{ type: 'text', text: validation.error }] };
5531
- }
5532
- const deps = buildRetypeSetScopeDeps();
5533
- const result = await executeRetype(validation.factId, validation.newType, deps);
5534
- if (result.success) {
5535
- api.logger.info(
5536
- `totalreclaw_retype: ${result.fact_id} (${result.previous_type} → ${result.new_type}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`,
5537
- );
5538
- return {
5539
- content: [{
5540
- type: 'text',
5541
- text:
5542
- `Retyped memory ${result.fact_id} from ${result.previous_type} to ${result.new_type}. ` +
5543
- `New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
5544
- }],
5545
- details: result,
5546
- };
5547
- }
5548
- api.logger.error(`totalreclaw_retype failed: ${result.error}`);
5549
- return {
5550
- content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(result.error ?? 'unknown error')}` }],
5551
- details: result,
5552
- };
5553
- } catch (err: unknown) {
5554
- const message = err instanceof Error ? err.message : String(err);
5555
- api.logger.error(`totalreclaw_retype failed: ${message}`);
5556
- return {
5557
- content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(message)}` }],
5558
- };
5559
- }
5560
- },
5561
- },
5562
- { name: 'totalreclaw_retype' },
5563
- );
5564
-
5565
- // ---------------------------------------------------------------
5566
- // Tool: totalreclaw_set_scope (3.3.1-rc.2 — agent-facing scope edit)
5567
- // ---------------------------------------------------------------
5568
-
5569
- api.registerTool(
5570
- {
5571
- name: 'totalreclaw_set_scope',
5572
- label: 'Set Scope',
5573
- description:
5574
- 'Move an existing memory to a different scope (work / personal / health / family / ' +
5575
- 'creative / finance / misc / unspecified). Use when the user re-categorizes a memory\'s ' +
5576
- 'domain — e.g. "put that under work", "this is a health thing", "move this to personal". ' +
5577
- 'Writes a new v1.1 blob with the updated scope and tombstones the old fact on-chain.\n\n' +
5578
- 'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
5579
- 'find the fact_id, then pass it here with the new scope.',
5580
- parameters: {
5581
- type: 'object',
5582
- properties: {
5583
- fact_id: {
5584
- type: 'string',
5585
- description:
5586
- 'The UUID of the memory to rescope. Get this from a prior `totalreclaw_recall` result.',
5587
- },
5588
- new_scope: {
5589
- type: 'string',
5590
- enum: ['work', 'personal', 'health', 'family', 'creative', 'finance', 'misc', 'unspecified'],
5591
- description:
5592
- 'The new scope. Used for filtered recall — e.g. "recall work-related memories only".',
5593
- },
5594
- },
5595
- required: ['fact_id', 'new_scope'],
5596
- additionalProperties: false,
5597
- },
5598
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5599
- try {
5600
- await requireFullSetup(api.logger);
5601
- if (!isSubgraphMode()) {
5602
- return {
5603
- content: [{
5604
- type: 'text',
5605
- text: 'Set-scope is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
5606
- }],
5607
- };
5608
- }
5609
- const validation = validateSetScopeArgs(params);
5610
- if (!validation.ok) {
5611
- return { content: [{ type: 'text', text: validation.error }] };
5612
- }
5613
- const deps = buildRetypeSetScopeDeps();
5614
- const result = await executeSetScope(validation.factId, validation.newScope, deps);
5615
- if (result.success) {
5616
- api.logger.info(
5617
- `totalreclaw_set_scope: ${result.fact_id} (${result.previous_scope ?? 'unspecified'} → ${result.new_scope}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`,
5618
- );
5619
- return {
5620
- content: [{
5621
- type: 'text',
5622
- text:
5623
- `Moved memory ${result.fact_id} from scope "${result.previous_scope ?? 'unspecified'}" to "${result.new_scope}". ` +
5624
- `New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
5625
- }],
5626
- details: result,
5627
- };
5628
- }
5629
- api.logger.error(`totalreclaw_set_scope failed: ${result.error}`);
5630
- return {
5631
- content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(result.error ?? 'unknown error')}` }],
5632
- details: result,
5633
- };
5634
- } catch (err: unknown) {
5635
- const message = err instanceof Error ? err.message : String(err);
5636
- api.logger.error(`totalreclaw_set_scope failed: ${message}`);
5637
- return {
5638
- content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(message)}` }],
5639
- };
5640
- }
5641
- },
5642
- },
5643
- { name: 'totalreclaw_set_scope' },
5644
- );
5645
-
5646
- // ---------------------------------------------------------------
5647
- // Tool: totalreclaw_import_from
5648
- // ---------------------------------------------------------------
5649
-
5650
- api.registerTool(
5651
- {
5652
- name: 'totalreclaw_import_from',
5653
- label: 'Import From',
5654
- description:
5655
- 'Import memories from other AI memory tools (Mem0, MCP Memory Server, ChatGPT, Claude, Gemini, MemoClaw, or generic JSON/CSV). ' +
5656
- 'Provide the source name and either an API key, file content, or file path. ' +
5657
- 'Use dry_run=true to preview before importing. Idempotent — safe to run multiple times.',
5658
- parameters: {
5659
- type: 'object',
5660
- properties: {
5661
- source: {
5662
- type: 'string',
5663
- enum: ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'],
5664
- description: 'The source system to import from (gemini: Google Takeout HTML; chatgpt: conversations.json or memory text; claude: memory text)',
5665
- },
5666
- api_key: {
5667
- type: 'string',
5668
- description: 'API key for the source system (used once, never stored)',
5669
- },
5670
- source_user_id: {
5671
- type: 'string',
5672
- description: 'User or agent ID in the source system',
5673
- },
5674
- content: {
5675
- type: 'string',
5676
- description: 'File content (JSON, JSONL, or CSV)',
5677
- },
5678
- file_path: {
5679
- type: 'string',
5680
- description: 'Path to the file on disk',
5681
- },
5682
- namespace: {
5683
- type: 'string',
5684
- description: 'Target namespace (default: "imported")',
5685
- },
5686
- dry_run: {
5687
- type: 'boolean',
5688
- description: 'Preview without importing',
5689
- },
5690
- },
5691
- required: ['source'],
5692
- },
5693
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5694
- try {
5695
- await requireFullSetup(api.logger);
5696
- return handlePluginImportFrom(params, api.logger);
5697
- } catch (err: unknown) {
5698
- const message = err instanceof Error ? err.message : String(err);
5699
- return { error: message };
5700
- }
5701
- },
5702
- },
5703
- { name: 'totalreclaw_import_from' },
5704
- );
5705
-
5706
- // ---------------------------------------------------------------
5707
- // Tool: totalreclaw_import_batch
5708
- // ---------------------------------------------------------------
5709
-
5710
- api.registerTool(
5711
- {
5712
- name: 'totalreclaw_import_batch',
5713
- label: 'Import Batch',
5714
- description:
5715
- 'Process one batch of a large import. Call repeatedly with increasing offset until is_complete=true.',
5716
- parameters: {
5717
- type: 'object',
5718
- properties: {
5719
- source: {
5720
- type: 'string',
5721
- enum: ['gemini', 'chatgpt', 'claude'],
5722
- description: 'Source format',
5723
- },
5724
- file_path: {
5725
- type: 'string',
5726
- description: 'Path to source file',
5727
- },
5728
- content: {
5729
- type: 'string',
5730
- description: 'File content (text sources)',
5731
- },
5732
- offset: {
5733
- type: 'number',
5734
- description: 'Starting chunk index (0-based)',
5735
- },
5736
- batch_size: {
5737
- type: 'number',
5738
- description: 'Chunks per call (default 25)',
5739
- },
5740
- },
5741
- required: ['source'],
5742
- },
5743
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5744
- try {
5745
- await requireFullSetup(api.logger);
5746
- return handleBatchImport(params, api.logger);
5747
- } catch (err: unknown) {
5748
- const message = err instanceof Error ? err.message : String(err);
5749
- return { error: message };
5750
- }
5751
- },
5752
- },
5753
- { name: 'totalreclaw_import_batch' },
5754
- );
5755
-
5756
- // ---------------------------------------------------------------
5757
- // Tool: totalreclaw_import_status
5758
- // ---------------------------------------------------------------
5759
-
5760
- api.registerTool(
5761
- {
5762
- name: 'totalreclaw_import_status',
5763
- label: 'Import Status',
5764
- description:
5765
- 'Check the progress of a background import. ' +
5766
- 'If import_id is omitted, returns the most recent active import. ' +
5767
- 'Returns status (running/completed/failed/aborted), batch progress, facts stored, and ETA. ' +
5768
- 'Use this when the user asks "how\'s the import?" or "is it done yet?".',
5769
- parameters: {
5770
- type: 'object',
5771
- properties: {
5772
- import_id: {
5773
- type: 'string',
5774
- description: 'The import ID returned by totalreclaw_import_from. Omit for most recent active import.',
5775
- },
5776
- },
5777
- additionalProperties: false,
5778
- },
5779
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5780
- try {
5781
- await requireFullSetup(api.logger);
5782
- return handleImportStatus(params, api.logger);
5783
- } catch (err: unknown) {
5784
- const message = err instanceof Error ? err.message : String(err);
5785
- return { error: message };
5786
- }
5787
- },
5788
- },
5789
- { name: 'totalreclaw_import_status' },
5790
- );
5791
-
5792
- // ---------------------------------------------------------------
5793
- // Tool: totalreclaw_import_abort
5794
- // ---------------------------------------------------------------
5795
-
5796
- api.registerTool(
5797
- {
5798
- name: 'totalreclaw_import_abort',
5799
- label: 'Abort Import',
5800
- description:
5801
- 'Cancel a running background import. Already-stored facts are kept (import is idempotent via fingerprint dedup). ' +
5802
- 'The background task will stop at the next chunk boundary after receiving the abort signal. ' +
5803
- 'Use when the user says "stop the import" or "cancel the import".',
5804
- parameters: {
5805
- type: 'object',
5806
- properties: {
5807
- import_id: {
5808
- type: 'string',
5809
- description: 'The import ID to abort (from totalreclaw_import_from or totalreclaw_import_status).',
5810
- },
5811
- },
5812
- required: ['import_id'],
5813
- additionalProperties: false,
5814
- },
5815
- async execute(_toolCallId: string, params: Record<string, unknown>) {
5816
- try {
5817
- await requireFullSetup(api.logger);
5818
- return handleImportAbort(params, api.logger);
5819
- } catch (err: unknown) {
5820
- const message = err instanceof Error ? err.message : String(err);
5821
- return { error: message };
5822
- }
5823
- },
5824
- },
5825
- { name: 'totalreclaw_import_abort' },
5826
- );
5827
-
5828
- // ---------------------------------------------------------------
5829
- // Tool: totalreclaw_upgrade
5830
- // ---------------------------------------------------------------
5831
-
5832
- api.registerTool(
5833
- {
5834
- name: 'totalreclaw_upgrade',
5835
- label: 'Upgrade to Pro',
5836
- description:
5837
- 'Upgrade to TotalReclaw Pro for unlimited encrypted memories. ' +
5838
- 'Returns a Stripe checkout URL for the user to complete payment via credit/debit card.',
5839
- parameters: {
5840
- type: 'object',
5841
- properties: {},
5842
- additionalProperties: false,
5843
- },
5844
- async execute() {
5845
- try {
5846
- await requireFullSetup(api.logger);
5847
-
5848
- if (!authKeyHex) {
5849
- return {
5850
- content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
5851
- };
5852
- }
5853
-
5854
- const serverUrl = CONFIG.serverUrl;
5855
- const walletAddr = subgraphOwner || userId || '';
5856
-
5857
- if (!walletAddr) {
5858
- return {
5859
- content: [{ type: 'text', text: 'Wallet address not available. Please ensure the plugin is fully initialized.' }],
5860
- };
5861
- }
5862
-
5863
- const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
5864
- method: 'POST',
5865
- headers: buildRelayHeaders({
5866
- 'Authorization': `Bearer ${authKeyHex}`,
5867
- 'Content-Type': 'application/json',
5868
- }),
5869
- body: JSON.stringify({
5870
- wallet_address: walletAddr,
5871
- tier: 'pro',
5872
- }),
5873
- });
5874
-
5875
- if (!response.ok) {
5876
- const body = await response.text().catch(() => '');
5877
- return {
5878
- content: [{ type: 'text', text: `Failed to create checkout session (HTTP ${response.status}): ${body || response.statusText}` }],
5879
- };
5880
- }
5881
-
5882
- const data = await response.json() as { checkout_url?: string };
5883
-
5884
- if (!data.checkout_url) {
5885
- return {
5886
- content: [{ type: 'text', text: 'Failed to create checkout session: no checkout URL returned.' }],
5887
- };
5888
- }
5889
-
5890
- return {
5891
- content: [{ type: 'text', text: `Open this URL to upgrade to Pro: ${data.checkout_url}` }],
5892
- details: { checkout_url: data.checkout_url },
5893
- };
5894
- } catch (err: unknown) {
5895
- const message = err instanceof Error ? err.message : String(err);
5896
- api.logger.error(`totalreclaw_upgrade failed: ${message}`);
5897
- return {
5898
- content: [{ type: 'text', text: `Failed to create checkout session: ${humanizeError(message)}` }],
5899
- };
5900
- }
5901
- },
5902
- },
5903
- { name: 'totalreclaw_upgrade' },
5904
- );
5905
-
5906
- // ---------------------------------------------------------------
5907
- // Tool: totalreclaw_migrate
5908
- // ---------------------------------------------------------------
5909
-
5910
- api.registerTool(
5911
- {
5912
- name: 'totalreclaw_migrate',
5913
- label: 'Migrate Testnet to Mainnet',
5914
- description:
5915
- 'Migrate memories from testnet (Base Sepolia) to mainnet (Gnosis) after upgrading to Pro. ' +
5916
- 'Dry-run by default — set confirm=true to execute. Idempotent: re-running skips already-migrated facts.',
5917
- parameters: {
5918
- type: 'object',
5919
- properties: {
5920
- confirm: {
5921
- type: 'boolean',
5922
- description: 'Set to true to execute the migration. Without it, returns a dry-run preview.',
5923
- default: false,
5924
- },
5925
- },
5926
- additionalProperties: false,
5927
- },
5928
- async execute(_params: { confirm?: boolean }) {
5929
- try {
5930
- await requireFullSetup(api.logger);
5931
-
5932
- if (!authKeyHex || !subgraphOwner) {
5933
- return {
5934
- content: [{ type: 'text', text: 'Plugin not fully initialized. Ensure TOTALRECLAW_RECOVERY_PHRASE is set.' }],
5935
- };
5936
- }
5937
-
5938
- if (!isSubgraphMode()) {
5939
- return {
5940
- content: [{ type: 'text', text: 'Migration is only available with the managed service (subgraph mode).' }],
5941
- };
5942
- }
5943
-
5944
- const confirm = _params?.confirm === true;
5945
- const serverUrl = CONFIG.serverUrl;
5946
-
5947
- // 1. Check billing tier
5948
- const billingResp = await fetch(
5949
- `${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`,
5950
- {
5951
- method: 'GET',
5952
- headers: buildRelayHeaders({
5953
- 'Authorization': `Bearer ${authKeyHex}`,
5954
- 'Content-Type': 'application/json',
5955
- }),
5956
- },
5957
- );
5958
- if (!billingResp.ok) {
5959
- return { content: [{ type: 'text', text: `Failed to check billing tier (HTTP ${billingResp.status}).` }] };
5960
- }
5961
- const billingData = await billingResp.json() as { tier: string };
5962
- if (billingData.tier !== 'pro') {
5963
- return {
5964
- content: [{ type: 'text', text: 'Migration requires Pro tier. Use totalreclaw_upgrade to upgrade first.' }],
5965
- };
5966
- }
5967
-
5968
- // 2. Fetch testnet facts via relay (chain=testnet query param)
5969
- const testnetSubgraphUrl = `${serverUrl}/v1/subgraph?chain=testnet`;
5970
- const mainnetSubgraphUrl = `${serverUrl}/v1/subgraph`;
5971
-
5972
- api.logger.info('Fetching testnet facts...');
5973
- const testnetFacts = await fetchAllFactsByOwner(testnetSubgraphUrl, subgraphOwner, authKeyHex);
5974
-
5975
- if (testnetFacts.length === 0) {
5976
- return {
5977
- content: [{ type: 'text', text: 'No facts found on testnet. Nothing to migrate.' }],
5978
- };
5979
- }
5980
-
5981
- // 3. Check mainnet for existing facts (idempotency)
5982
- api.logger.info('Checking mainnet for existing facts...');
5983
- const mainnetFps = await fetchContentFingerprintsByOwner(mainnetSubgraphUrl, subgraphOwner, authKeyHex);
5984
- const factsToMigrate = testnetFacts.filter(f => !f.contentFp || !mainnetFps.has(f.contentFp));
5985
- const alreadyOnMainnet = testnetFacts.length - factsToMigrate.length;
5986
-
5987
- // 4. Dry-run
5988
- if (!confirm) {
5989
- const msg = factsToMigrate.length === 0
5990
- ? `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.`
5991
- : `Found ${factsToMigrate.length} facts to migrate from testnet to Gnosis mainnet (${alreadyOnMainnet} already on mainnet). Call with confirm=true to proceed.`;
5992
- return {
5993
- content: [{ type: 'text', text: msg }],
5994
- details: {
5995
- mode: 'dry_run',
5996
- testnet_facts: testnetFacts.length,
5997
- already_on_mainnet: alreadyOnMainnet,
5998
- to_migrate: factsToMigrate.length,
5999
- },
6000
- };
6001
- }
6002
-
6003
- // 5. Execute migration
6004
- if (factsToMigrate.length === 0) {
6005
- return {
6006
- content: [{ type: 'text', text: `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.` }],
6007
- };
6008
- }
6009
-
6010
- // Fetch blind indices
6011
- api.logger.info(`Fetching blind indices for ${factsToMigrate.length} facts...`);
6012
- const factIds = factsToMigrate.map(f => f.id);
6013
- const blindIndicesMap = await fetchBlindIndicesByFactIds(testnetSubgraphUrl, factIds, authKeyHex);
6014
-
6015
- // Build protobuf payloads
6016
- const payloads: Buffer[] = [];
6017
- for (const fact of factsToMigrate) {
6018
- const blobHex = fact.encryptedBlob.startsWith('0x') ? fact.encryptedBlob.slice(2) : fact.encryptedBlob;
6019
- const indices = blindIndicesMap.get(fact.id) || [];
6020
- const factPayload: FactPayload = {
6021
- id: fact.id,
6022
- timestamp: new Date().toISOString(),
6023
- owner: subgraphOwner,
6024
- encryptedBlob: blobHex,
6025
- blindIndices: indices,
6026
- decayScore: parseFloat(fact.decayScore) || 0.5,
6027
- source: fact.source || 'migration',
6028
- contentFp: fact.contentFp || '',
6029
- agentId: fact.agentId || 'openclaw-plugin',
6030
- encryptedEmbedding: fact.encryptedEmbedding || undefined,
6031
- version: PROTOBUF_VERSION_V4,
6032
- };
6033
- payloads.push(encodeFactProtobuf(factPayload));
6034
- }
6035
-
6036
- // Batch submit (15 per UserOp)
6037
- const BATCH_SIZE = 15;
6038
- const batchConfig = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner ?? undefined };
6039
- let migrated = 0;
6040
- let failedBatches = 0;
6041
-
6042
- for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
6043
- const batch = payloads.slice(i, i + BATCH_SIZE);
6044
- const batchNum = Math.floor(i / BATCH_SIZE) + 1;
6045
- const totalBatches = Math.ceil(payloads.length / BATCH_SIZE);
6046
- api.logger.info(`Migrating batch ${batchNum}/${totalBatches} (${batch.length} facts)...`);
6047
-
6048
- try {
6049
- const result = await submitFactBatchOnChain(batch, batchConfig);
6050
- if (result.success) {
6051
- migrated += batch.length;
6052
- } else {
6053
- failedBatches++;
6054
- }
6055
- } catch (err: unknown) {
6056
- const msg = err instanceof Error ? err.message : String(err);
6057
- api.logger.error(`Migration batch ${batchNum} failed: ${msg}`);
6058
- failedBatches++;
6059
- }
6060
- }
6061
-
6062
- const resultMsg = failedBatches === 0
6063
- ? `Successfully migrated ${migrated} memories from testnet to Gnosis mainnet.`
6064
- : `Migrated ${migrated}/${factsToMigrate.length} memories. ${failedBatches} batch(es) failed — re-run to retry (idempotent).`;
6065
-
6066
- return {
6067
- content: [{ type: 'text', text: resultMsg }],
6068
- details: {
6069
- mode: 'executed',
6070
- testnet_facts: testnetFacts.length,
6071
- already_on_mainnet: alreadyOnMainnet,
6072
- to_migrate: factsToMigrate.length,
6073
- migrated,
6074
- failed_batches: failedBatches,
6075
- },
6076
- };
6077
- } catch (err: unknown) {
6078
- const message = err instanceof Error ? err.message : String(err);
6079
- api.logger.error(`totalreclaw_migrate failed: ${message}`);
6080
- return {
6081
- content: [{ type: 'text', text: `Migration failed: ${humanizeError(message)}` }],
6082
- };
6083
- }
6084
- },
6085
- },
6086
- { name: 'totalreclaw_migrate' },
6087
- );
6088
-
6089
- // ---------------------------------------------------------------
6090
- // Tools: totalreclaw_setup + totalreclaw_onboarding_start —
6091
- // REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
6092
- // ---------------------------------------------------------------
6093
- //
6094
- // rc.4 left these two registrations in place as *neutered* stubs —
6095
- // ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
6096
- // and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
6097
- // was already pointer-only. Neither path could leak a phrase in
6098
- // rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
6099
- // regression surface: any future patch that re-enables phrase
6100
- // acceptance (e.g. a flag-driven "power-user" path) would silently
6101
- // re-open the leak, and their mere presence in the tool registry
6102
- // keeps signalling to agents that "phrase handling happens here".
6103
- //
6104
- // Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
6105
- // facilitated setup surface is ``totalreclaw_pair`` (browser-side
6106
- // crypto keeps the phrase out of the LLM round-trip by construction).
6107
- // rc.5 deletes both registrations outright. The underlying CLI
6108
- // wizard (``openclaw totalreclaw onboard``) is unchanged — users
6109
- // run it in their own terminal, outside any agent shell.
6110
- //
6111
- // Audit assertion: ``phrase-safety-registry.test.ts`` asserts
6112
- // neither name is present in the ``api.registerTool`` call list.
6113
- // Re-adding either fails CI.
6114
- //
6115
- // Historical tombstone (so LLM-assisted contributors don't re-add
6116
- // the former shape from training-data memory): rc.4 registered two
6117
- // tools by the names "totalreclaw_setup" and
6118
- // "totalreclaw_onboarding_start" as pointer-only stubs. Both were
6119
- // deleted in rc.5. Do not re-introduce.
6120
-
6121
- // ---------------------------------------------------------------
6122
- // Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
6123
- //
6124
- // rc.3 shipped a `totalreclaw_onboard` agent tool that generated a
6125
- // fresh BIP-39 mnemonic in-process, wrote it to credentials.json,
6126
- // and returned `{scope_address, credentials_path}` to the agent.
6127
- // `emitPhrase: false` kept the mnemonic OUT of the tool's return
6128
- // payload, but NOTHING ARCHITECTURALLY PREVENTED leakage — a future
6129
- // patch could regress the flag, a different code path could echo
6130
- // the mnemonic in a log/error message the agent captures, or the
6131
- // mere existence of the tool implied to agents that "generating a
6132
- // phrase here is fine" (it isn't).
6133
- //
6134
- // Per ``project_phrase_safety_rule.md``
6135
- // (memory file in p-diogo/totalreclaw-internal — absolute rule:
6136
- // "recovery phrase MUST NEVER cross the LLM context in ANY form"),
6137
- // phrase-generating agent tools are forbidden. The ONLY approved
6138
- // agent-facilitated setup surface is ``totalreclaw_pair`` (browser-
6139
- // side crypto keeps the phrase out of the LLM round-trip by
6140
- // construction). The underlying ``runNonInteractiveOnboard`` code
6141
- // path is still reachable via the CLI ``openclaw totalreclaw onboard``
6142
- // — that path runs in the user's own terminal, OUTSIDE any agent
6143
- // shell, so phrase stdout never feeds back into LLM context.
6144
- //
6145
- // Audit assertion: ``tool-gating.test.ts`` enforces the removal —
6146
- // any future re-registration of ``totalreclaw_onboard`` (or any
6147
- // phrase-generating variant like ``totalreclaw_onboard_generate``,
6148
- // ``totalreclaw_restore_phrase``) fails CI.
6149
- // ---------------------------------------------------------------
6150
-
6151
- // ---------------------------------------------------------------
6152
- // Tool: totalreclaw_pair (3.3.1-rc.2 — agent-callable pair-generate)
6153
- //
6154
- // Creates a pairing session (browser-mediated recovery-phrase sync),
6155
- // returns the URL + PIN + QR ASCII to the agent. The agent relays
6156
- // these to the user (paste-URL or scan-QR flow). The phrase itself
6157
- // NEVER crosses the gateway — the pair-http endpoint does the E2EE
6158
- // handshake with the browser pair-page.
6159
- // ---------------------------------------------------------------
6160
- api.registerTool(
6161
- {
6162
- name: 'totalreclaw_pair',
6163
- label: 'Set up the user\'s TotalReclaw account (encrypted, browser-side)',
6164
- description:
6165
- 'Set up the user\'s TotalReclaw account (encrypted, browser-side recovery-phrase ' +
6166
- 'generation or import). Starts a brief remote session so the user can create or ' +
6167
- 'import a TotalReclaw recovery phrase from their phone or another browser. ' +
6168
- 'Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent ' +
6169
- 'relays to the user. The recovery phrase itself is generated/entered in the ' +
6170
- 'BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches ' +
6171
- 'the LLM provider or the chat transcript.\n\n' +
6172
- 'This is the CANONICAL agent-facilitated account-setup surface — use it whenever ' +
6173
- 'the user asks you to set up TotalReclaw, regardless of whether they have ' +
6174
- 'terminal access. Narrate it to the user as "setting up your TotalReclaw ' +
6175
- 'account". Browser-side crypto keeps the recovery phrase out of the LLM context ' +
6176
- 'entirely. If a user explicitly prefers local-terminal account setup with no ' +
6177
- 'browser, point them at `totalreclaw_onboarding_start` (a pointer to the CLI ' +
6178
- 'wizard they run on their own terminal, NOT through your shell tool).\n\n' +
6179
- 'Tool name `totalreclaw_pair` is kept for backward compatibility — function-wise ' +
6180
- 'this is the account-setup tool.',
6181
- parameters: {
6182
- type: 'object',
6183
- properties: {
6184
- mode: {
6185
- type: 'string',
6186
- enum: ['generate', 'import'],
6187
- description:
6188
- '"generate" = the browser will create a NEW recovery phrase. "import" = the ' +
6189
- 'browser will accept an EXISTING phrase that the user pastes in their browser ' +
6190
- '(never through chat).',
6191
- default: 'generate',
6192
- },
6193
- },
6194
- additionalProperties: false,
6195
- },
6196
- async execute(_toolCallId: string, params: Record<string, unknown>) {
6197
- const rawMode = params?.mode;
6198
- const mode: 'generate' | 'import' =
6199
- rawMode === 'import' ? 'import' : 'generate';
6200
- const pairMode = CONFIG.pairMode;
6201
- try {
6202
- // 3.3.1-rc.11 — relay-brokered pair by default (universal reachability).
6203
- // `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
6204
- // for air-gapped / self-hosted setups. Both paths return the same
6205
- // tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
6206
- // only the URL origin differs.
6207
- let url: string;
6208
- let pin: string;
6209
- let sidOrToken: string;
6210
- let expiresAtMs: number;
6211
- let localSession: import('./pair-session-store.js').PairSession | undefined;
6212
-
6213
- if (pairMode === 'relay') {
6214
- const { openRemotePairSession, awaitPhraseUpload } = await import(
6215
- './pair-remote-client.js'
6216
- );
6217
- const remoteSession = await openRemotePairSession({
6218
- relayBaseUrl: CONFIG.pairRelayUrl,
6219
- mode: mode === 'generate' ? 'generate' : 'import',
6220
- });
6221
- url = remoteSession.url;
6222
- pin = remoteSession.pin;
6223
- sidOrToken = remoteSession.token;
6224
- // Relay sends ISO-8601; convert to ms for tool payload parity.
6225
- const parsed = Date.parse(remoteSession.expiresAt);
6226
- expiresAtMs = Number.isFinite(parsed)
6227
- ? parsed
6228
- : Date.now() + 5 * 60_000;
6229
- // Background task — writes credentials.json + flips state when
6230
- // the browser completes the flow. Tool handler returns
6231
- // immediately so the agent can tell the user the URL + PIN.
6232
- //
6233
- // 3.3.4-rc.2 (Pedro QA — pair flow stuck-session) — wrap the
6234
- // WS-await in a 60s hard timeout. The relay drops sessions on
6235
- // `ws_close` (separately patched relay-side); without this
6236
- // bound the background task hangs in `waitNextMessage` for the
6237
- // full 5-minute session TTL before resolving, and downstream
6238
- // tooling that polls the gateway for completion sees stale
6239
- // `processing` state at 129s/159s/189s. 60s is the user-side
6240
- // deadline we surface in chat: long enough for a slow scan-
6241
- // and-paste, short enough that a fresh URL request is the
6242
- // obvious next step. Structured `timed_out` error in the log
6243
- // lets ops grep for the failure mode independently of generic
6244
- // ws-close errors.
6245
- const PAIR_TOOL_HARD_TIMEOUT_MS = 60_000;
6246
- void (async () => {
6247
- try {
6248
- const phraseUploadPromise = awaitPhraseUpload(remoteSession, {
6249
- phraseValidator: (p: string) =>
6250
- validateMnemonic(p, wordlist),
6251
- completePairing: async ({ mnemonic }) => {
6252
- try {
6253
- // 3.3.1 (internal#130) — derive + persist the
6254
- // Smart Account address now so the user can see
6255
- // it immediately after pair, before any chain
6256
- // write. Mnemonic stays in this scope (already
6257
- // on disk in credentials.json); only the
6258
- // derived public scope_address is added.
6259
- let scopeAddress: string | undefined;
6260
- try {
6261
- scopeAddress = await deriveSmartAccountAddress(
6262
- mnemonic,
6263
- CONFIG.chainId,
6264
- );
6265
- } catch (deriveErr) {
6266
- api.logger.warn(
6267
- `totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
6268
- );
6269
- }
6270
- const creds =
6271
- loadCredentialsJson(CREDENTIALS_PATH) ?? {};
6272
- const next: typeof creds = { ...creds, mnemonic };
6273
- if (scopeAddress) next.scope_address = scopeAddress;
6274
- if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
6275
- return { state: 'error', error: 'credentials_write_failed' };
6276
- }
6277
- setRecoveryPhraseOverride(mnemonic);
6278
- writeOnboardingState(CONFIG.onboardingStatePath, {
6279
- onboardingState: 'active',
6280
- createdBy: mode === 'generate' ? 'generate' : 'import',
6281
- credentialsCreatedAt: new Date().toISOString(),
6282
- version: pluginVersion ?? '3.3.0',
6283
- });
6284
- // 3.3.13 — sentinel cleanup; see auto-pair-on-load.ts.
6285
- deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
6286
- api.logger.info(
6287
- `totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`,
6288
- );
6289
- return { state: 'active' };
6290
- } catch (err: unknown) {
6291
- const msg = err instanceof Error ? err.message : String(err);
6292
- api.logger.error(
6293
- `totalreclaw_pair(relay): completePairing failed: ${msg}`,
6294
- );
6295
- return { state: 'error', error: msg };
6296
- }
6297
- },
6298
- // 3.3.4-rc.2 — also pass through to awaitPhraseUpload so its
6299
- // internal `waitNextMessage` timer matches the outer race.
6300
- timeoutMs: PAIR_TOOL_HARD_TIMEOUT_MS,
6301
- });
6302
- // 3.3.4-rc.2 — outer Promise.race guard. Resolves to a
6303
- // sentinel ({ status: 'timed_out', ... }) so the catch
6304
- // handler can distinguish a hard-timeout from a generic
6305
- // ws-close error and surface it explicitly.
6306
- const TIMEOUT_SENTINEL: {
6307
- status: 'timed_out';
6308
- message: string;
6309
- } = {
6310
- status: 'timed_out',
6311
- message:
6312
- `Pair flow timed out (${PAIR_TOOL_HARD_TIMEOUT_MS / 1000}s) — generate a new URL with totalreclaw_pair.`,
6313
- };
6314
- let hardTimer: ReturnType<typeof setTimeout> | undefined;
6315
- const hardTimeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>(
6316
- (resolve) => {
6317
- hardTimer = setTimeout(
6318
- () => resolve(TIMEOUT_SENTINEL),
6319
- PAIR_TOOL_HARD_TIMEOUT_MS,
6320
- );
6321
- },
6322
- );
6323
- try {
6324
- const raced = await Promise.race([
6325
- phraseUploadPromise,
6326
- hardTimeoutPromise,
6327
- ]);
6328
- if (
6329
- raced &&
6330
- typeof raced === 'object' &&
6331
- (raced as { status?: unknown }).status === 'timed_out'
6332
- ) {
6333
- api.logger.warn(
6334
- `totalreclaw_pair(relay): hard timeout — ${(raced as typeof TIMEOUT_SENTINEL).message} (token=${remoteSession.token.slice(0, 8)}…)`,
6335
- );
6336
- }
6337
- } finally {
6338
- if (hardTimer) clearTimeout(hardTimer);
6339
- }
6340
- } catch (bgErr: unknown) {
6341
- // Expected on TTL expiry / user-aborts — log at warn, not error.
6342
- const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
6343
- api.logger.warn(
6344
- `totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`,
6345
- );
6346
- }
6347
- })();
6348
- } else {
6349
- // Local loopback path (rc.10 behaviour).
6350
- const { createPairSession } = await import('./pair-session-store.js');
6351
- const { generateGatewayKeypair } = await import('./pair-crypto.js');
6352
- const kp = generateGatewayKeypair();
6353
- const session = await createPairSession(CONFIG.pairSessionsPath, {
6354
- mode,
6355
- operatorContext: { channel: 'agent' },
6356
- rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
6357
- rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
6358
- });
6359
- url = buildPairingUrl(api, session);
6360
- pin = session.secondaryCode;
6361
- sidOrToken = session.sid;
6362
- expiresAtMs = session.expiresAtMs;
6363
- localSession = session;
6364
- }
6365
-
6366
- // QR renderers — same for both modes; input is the URL string.
6367
- const { defaultRenderQr } = await import('./pair-cli.js');
6368
- const qrAscii = await new Promise<string>((resolve) => {
6369
- let settled = false;
6370
- const t = setTimeout(() => {
6371
- if (!settled) {
6372
- settled = true;
6373
- resolve('');
6374
- }
6375
- }, 5_000);
6376
- try {
6377
- defaultRenderQr(url, (ascii: string) => {
6378
- if (settled) return;
6379
- settled = true;
6380
- clearTimeout(t);
6381
- resolve(ascii);
6382
- });
6383
- } catch {
6384
- if (settled) return;
6385
- settled = true;
6386
- clearTimeout(t);
6387
- resolve('');
6388
- }
6389
- });
6390
-
6391
- // 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
6392
- let qrPngB64 = '';
6393
- let qrUnicode = '';
6394
- try {
6395
- const { encodePng, encodeUnicode } = await import('./pair-qr.js');
6396
- const [pngBuf, uni] = await Promise.all([
6397
- encodePng(url),
6398
- encodeUnicode(url),
6399
- ]);
6400
- qrPngB64 = pngBuf.toString('base64');
6401
- qrUnicode = uni;
6402
- } catch (qrErr: unknown) {
6403
- api.logger.warn(
6404
- `totalreclaw_pair: QR encode failed (non-fatal): ${
6405
- qrErr instanceof Error ? qrErr.message : String(qrErr)
6406
- }`,
6407
- );
6408
- }
6409
-
6410
- api.logger.info(
6411
- `totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`,
6412
- );
6413
- // Voidly reference localSession so TS does not flag the unused
6414
- // local-branch binding. Future rc.12 diagnostics can expose
6415
- // `session.mode` / `session.status` separately.
6416
- void localSession;
6417
- return {
6418
- content: [{
6419
- type: 'text',
6420
- text:
6421
- `Pairing session started.\n\n` +
6422
- `URL: ${url}\n\n` +
6423
- `PIN (type this into the browser): ${pin}\n\n` +
6424
- (qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
6425
- `Instructions for the user:\n` +
6426
- `1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
6427
- `2. ` +
6428
- (mode === 'generate'
6429
- ? `The browser will generate a NEW 12-word recovery phrase and ask the user to write it down + retype 3 words before finalizing.`
6430
- : `The browser will accept an EXISTING phrase that the user pastes in the browser (never through chat).`) +
6431
- `\n3. Enter the 6-digit PIN shown above into the browser.\n` +
6432
- `4. The encrypted phrase uploads to this gateway — it NEVER touches the LLM.\n` +
6433
- `5. Come back to chat once the browser says "Pairing complete".\n\n` +
6434
- `This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
6435
- }],
6436
- details: {
6437
- sid: sidOrToken,
6438
- url,
6439
- pin,
6440
- mode,
6441
- expires_at_ms: expiresAtMs,
6442
- qr_ascii: qrAscii,
6443
- qr_png_b64: qrPngB64,
6444
- qr_unicode: qrUnicode,
6445
- // rc.11 — surface the transport so downstream tooling (QA
6446
- // harness asserters, telemetry) can confirm which path
6447
- // served the URL. Either 'relay' or 'local'.
6448
- transport: pairMode,
6449
- },
6450
- };
6451
- } catch (err: unknown) {
6452
- const message = err instanceof Error ? err.message : String(err);
6453
- api.logger.error(`totalreclaw_pair failed: ${message}`);
6454
- return {
6455
- content: [{ type: 'text', text: `Failed to start pairing session: ${humanizeError(message)}` }],
6456
- details: { error: message },
6457
- };
6458
- }
6459
- },
6460
- },
6461
- { name: 'totalreclaw_pair' },
6462
- );
6463
- // 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
6464
- // ops/QA can grep gateway logs for definitive proof the tool was
6465
- // declared. If the agent then reports the tool is missing from its
6466
- // tool list, the gap is upstream OpenClaw tool propagation, not our
6467
- // plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
6468
- //
6469
- // 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
6470
- // bleeding into `openclaw agent --json` stdout, breaking programmatic
6471
- // parsers that expect the JSON-RPC body to be the only thing on the
6472
- // wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
6473
- // `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
6474
- // a tool-injection regression. Default OFF — clean stdout for users.
6475
- if (CONFIG.verboseRegister) {
6476
- api.logger.info(
6477
- 'TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
6478
- 'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
6479
- '`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.',
6480
- );
6481
- }
6482
-
6483
- // ---------------------------------------------------------------
6484
- // Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
6485
- //
6486
- // Lets the agent file a structured QA-bug issue to
6487
- // `p-diogo/totalreclaw-internal` during RC testing. Only registered
6488
- // when the plugin version contains `-rc.` — stable users never see it.
6489
- //
6490
- // Secrets (recovery phrases, API keys, Telegram bot tokens) are
6491
- // redacted inside `postQaBugIssue` before the POST. The agent should
6492
- // still avoid passing raw secrets — see SKILL.md addendum.
6493
- // ---------------------------------------------------------------
6494
- if (rcMode) {
6495
- api.registerTool(
6496
- {
6497
- name: 'totalreclaw_report_qa_bug',
6498
- label: 'File a QA bug issue (RC builds only)',
6499
- description:
6500
- 'File a structured QA bug report to the internal tracker. RC-only; never available in stable builds. ' +
6501
- 'Do NOT call auto-file — ask the user first before invoking. The tool redacts recovery phrases, API keys, ' +
6502
- 'and Telegram bot tokens from all free-text fields before posting, but the agent SHOULD still avoid ' +
6503
- 'passing raw secrets.',
6504
- parameters: {
6505
- type: 'object',
6506
- properties: {
6507
- integration: {
6508
- type: 'string',
6509
- enum: ['plugin', 'hermes', 'nanoclaw', 'mcp', 'relay', 'clawhub', 'docs', 'other'],
6510
- description: 'Which TotalReclaw surface is affected.',
6511
- },
6512
- rc_version: {
6513
- type: 'string',
6514
- description: 'Exact RC version string (e.g. "3.3.1-rc.3" or "2.3.1rc3").',
6515
- },
6516
- severity: {
6517
- type: 'string',
6518
- enum: ['blocker', 'high', 'medium', 'low'],
6519
- description: 'blocker=release blocked, high=major UX failure, medium=annoying, low=polish.',
6520
- },
6521
- title: {
6522
- type: 'string',
6523
- description: 'Short summary, <60 chars. Prefix "[qa-bug]" is added automatically.',
6524
- maxLength: 60,
6525
- },
6526
- symptom: {
6527
- type: 'string',
6528
- description: 'What happened (redacted automatically).',
6529
- },
6530
- expected: {
6531
- type: 'string',
6532
- description: 'What should have happened.',
6533
- },
6534
- repro: {
6535
- type: 'string',
6536
- description: 'Reproduction steps (redacted automatically).',
6537
- },
6538
- logs: {
6539
- type: 'string',
6540
- description: 'Log excerpts / error messages (redacted automatically).',
6541
- },
6542
- environment: {
6543
- type: 'string',
6544
- description: 'Host, Docker/native, OpenClaw version, LLM provider, etc.',
6545
- },
6546
- },
6547
- required: [
6548
- 'integration',
6549
- 'rc_version',
6550
- 'severity',
6551
- 'title',
6552
- 'symptom',
6553
- 'expected',
6554
- 'repro',
6555
- 'logs',
6556
- 'environment',
6557
- ],
6558
- additionalProperties: false,
6559
- },
6560
- async execute(_toolCallId: string, params: Record<string, unknown>) {
6561
- try {
6562
- const { postQaBugIssue } = await import('./qa-bug-report.js');
6563
- // The token is resolved via CONFIG (config.ts) so index.ts
6564
- // stays clean of env-harvesting triggers.
6565
- const token = CONFIG.qaGithubToken;
6566
- if (!token) {
6567
- return {
6568
- content: [{
6569
- type: 'text',
6570
- text:
6571
- 'Cannot file QA bug: no GitHub token found. The operator must export ' +
6572
- 'TOTALRECLAW_QA_GITHUB_TOKEN (or GITHUB_TOKEN) with `repo` scope to enable ' +
6573
- 'agent-filed bug reports during RC testing.',
6574
- }],
6575
- details: { error: 'missing_github_token' },
6576
- };
6577
- }
6578
- // rc.14 — `repo` is resolved inside `postQaBugIssue` via
6579
- // `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
6580
- // refuses any slug that isn't a `-internal` fork. Pass the
6581
- // config-surfaced override so env reads stay in config.ts.
6582
- const repoOverride = CONFIG.qaRepoOverride || undefined;
6583
- const result = await postQaBugIssue(
6584
- params as unknown as import('./qa-bug-report.js').QaBugArgs,
6585
- {
6586
- githubToken: token,
6587
- repo: repoOverride,
6588
- logger: api.logger,
6589
- },
6590
- );
6591
- return {
6592
- content: [{
6593
- type: 'text',
6594
- text: `Filed QA bug #${result.issue_number}: ${result.issue_url}`,
6595
- }],
6596
- details: { issue_url: result.issue_url, issue_number: result.issue_number },
6597
- };
6598
- } catch (err: unknown) {
6599
- const message = err instanceof Error ? err.message : String(err);
6600
- api.logger.error(`totalreclaw_report_qa_bug failed: ${message}`);
6601
- return {
6602
- content: [{
6603
- type: 'text',
6604
- text: `Failed to file QA bug: ${message}`,
6605
- }],
6606
- details: { error: message },
6607
- };
6608
- }
6609
- },
6610
- },
6611
- { name: 'totalreclaw_report_qa_bug' },
6612
- );
6613
- // 3.3.1-rc.21 (issue #128): demote the registration-confirmation
6614
- // breadcrumb to verbose-only. Same `--json` stdout pollution risk
6615
- // as the totalreclaw_pair breadcrumb above; ops can opt back in
6616
- // via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
6617
- if (CONFIG.verboseRegister) {
6618
- api.logger.info(
6619
- 'totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).',
6620
- );
6621
- }
6622
- }
6623
-
6624
- // ---------------------------------------------------------------
6625
- // Hook: before_tool_call (3.2.0 memory-tool gate)
4562
+ // Hook: before_tool_call (memory-tool gate)
6626
4563
  // ---------------------------------------------------------------
6627
4564
  //
6628
- // Blocks every memory tool until onboarding state is `active`. The
6629
- // `blockReason` string is LLM-visible but carries no secret — it's a
6630
- // pointer to the CLI wizard.
4565
+ // Phase 3.3: gates the bundled NATIVE memory tools (memory_search,
4566
+ // memory_get) until onboarding state is `active`. The blockReason string
4567
+ // is LLM-visible but carries no secret — it's a pointer to the CLI pair
4568
+ // surface (`tr pair --url-pin`). Without this gate, an unpaired agent
4569
+ // would hit the adapter's fail-soft empty-result path and surface
4570
+ // "no memories found" with no actionable guidance.
6631
4571
  //
6632
- // Non-gated tools: totalreclaw_upgrade, totalreclaw_migrate,
6633
- // totalreclaw_onboarding_start, totalreclaw_setup (deprecated).
6634
- // Billing tools work pre-onboarding because they help the user reach a
6635
- // Pro tier before they have memories to store; setup-adjacent tools
6636
- // return their own routing messages.
4572
+ // Non-gated tools: every other tool the agent sees (read/write helpers,
4573
+ // the host's own tools, etc.). The pair surface is intentionally not
4574
+ // gated users must be able to start onboarding before the vault is
4575
+ // active.
6637
4576
  //
6638
4577
  // Decision logic lives in `tool-gating.ts` so it's unit-testable
6639
4578
  // without a full plugin host.
@@ -6795,18 +4734,18 @@ const plugin = {
6795
4734
  '## TotalReclaw setup pending\n\n' +
6796
4735
  'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
6797
4736
  'If the user asks about memory features or wants to configure TotalReclaw, ' +
6798
- 'call the `totalreclaw_pair` tool it returns a pairing URL + 6-digit PIN ' +
6799
- 'the user opens in a browser (phrase is generated and encrypted in-browser, ' +
6800
- 'never leaves their device in plaintext). If `totalreclaw_pair` is not in ' +
6801
- 'your tool list (container-based agents sometimes miss it), shell out to ' +
6802
- '`openclaw totalreclaw pair generate --url-pin-only` on the gateway host ' +
6803
- 'it emits a single JSON line `{v,url,pin,expires_at_ms}` with no phrase ' +
6804
- 'material; hand the user the `url` and `pin`.\n\n' +
4737
+ 'pairing is QR-only and CLI-driven (there is no agent tool for it). Run ' +
4738
+ '`tr pair --url-pin` in a shell on the gateway host it emits a single JSON ' +
4739
+ 'line `{v,url,pin,expires_at_ms}` with no phrase material. Hand the user the ' +
4740
+ '`url` and `pin`; they open the URL in a browser where the recovery phrase is ' +
4741
+ 'generated and encrypted in-browser and never leaves their device in plaintext. ' +
4742
+ 'Once pairing completes, memory_search/memory_get unlock automatically and this ' +
4743
+ 'banner stops appearing.\n\n' +
6805
4744
  '**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
6806
4745
  '**Do NOT** run `openclaw totalreclaw onboard` — that CLI emits the recovery ' +
6807
- 'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
6808
- '(tool or `--url-pin-only` CLI) instead; `onboard` is reserved for users ' +
6809
- 'running it directly in their own local terminal.',
4746
+ 'phrase on stdout and would leak it into the LLM transcript. Use `tr pair --url-pin` ' +
4747
+ '(or `openclaw totalreclaw pair generate --url-pin-only`) instead; `onboard` is ' +
4748
+ 'reserved for users running it directly in their own local terminal.',
6810
4749
  };
6811
4750
  }
6812
4751
 
@@ -6822,7 +4761,7 @@ const plugin = {
6822
4761
  const tier = cache?.tier || 'free';
6823
4762
  const tierInfo = tier === 'pro'
6824
4763
  ? 'You are on the **Pro** tier — unlimited memories, permanently stored on Gnosis mainnet.'
6825
- : 'You are on the **Free** tier — memories stored on testnet. Use the totalreclaw_upgrade tool to upgrade to Pro for permanent on-chain storage.';
4764
+ : '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.)';
6826
4765
  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}`;
6827
4766
  }
6828
4767
 
@@ -6845,6 +4784,10 @@ const plugin = {
6845
4784
  free_writes_used: (billingData.free_writes_used as number) ?? 0,
6846
4785
  free_writes_limit: (billingData.free_writes_limit as number) ?? 0,
6847
4786
  features: billingData.features as BillingCache['features'] | undefined,
4787
+ // Relay's authoritative chain_id → drives the chain override verbatim (#402).
4788
+ chain_id: billingData.chain_id as number | undefined,
4789
+ // Relay's authoritative data_edge_address → drives the DataEdge override verbatim (#460).
4790
+ data_edge_address: billingData.data_edge_address as string | undefined,
6848
4791
  checked_at: Date.now(),
6849
4792
  };
6850
4793
  writeBillingCache(cache);
@@ -6853,7 +4796,7 @@ const plugin = {
6853
4796
  if (cache && cache.free_writes_limit > 0) {
6854
4797
  const usageRatio = cache.free_writes_used / cache.free_writes_limit;
6855
4798
  if (usageRatio >= QUOTA_WARNING_THRESHOLD) {
6856
- 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.`;
4799
+ 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.`;
6857
4800
  }
6858
4801
  }
6859
4802
  } catch {
@@ -7001,7 +4944,7 @@ const plugin = {
7001
4944
 
7002
4945
  // 5. Decrypt subgraph results and build reranker input.
7003
4946
  const rerankerCandidates: RerankerCandidate[] = [];
7004
- const hookMetaMap = new Map<string, { importance: number; age: string }>();
4947
+ const hookMetaMap = new Map<string, { importance: number; age: string; category?: string }>();
7005
4948
 
7006
4949
  for (const result of subgraphResults) {
7007
4950
  try {
@@ -7050,7 +4993,7 @@ const plugin = {
7050
4993
  rerankerCandidates,
7051
4994
  8,
7052
4995
  INTENT_WEIGHTS[hookQueryIntent],
7053
- /* applySourceWeights (Retrieval v2 Tier 1) */ true,
4996
+ /* applySourceWeights (Retrieval v2 Tier 1) */ false,
7054
4997
  );
7055
4998
 
7056
4999
  // Update hot cache with reranked results.
@@ -7076,15 +5019,19 @@ const plugin = {
7076
5019
 
7077
5020
  // Relevance gate removed in rc.22 (see recall tool comment).
7078
5021
 
7079
- // 7. Build context string.
7080
- const lines = reranked.map((m, i) => {
5022
+ // 7. Build context string using core's unified recall formatter (adds dates + header).
5023
+ const recallItems = reranked.map((m) => {
7081
5024
  const meta = hookMetaMap.get(m.id);
7082
- const importance = meta?.importance ?? 5;
7083
- const age = meta?.age ?? '';
7084
- const typeTag = meta?.category ? `[${meta.category}] ` : '';
7085
- return `${i + 1}. ${typeTag}${m.text} (importance: ${importance}/10, ${age})`;
5025
+ return {
5026
+ category: meta?.category ?? 'claim',
5027
+ text: m.text,
5028
+ created_at: m.createdAt ?? 0,
5029
+ };
7086
5030
  });
7087
- const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
5031
+ const contextString = getSmartImportWasm().formatRecallContext(
5032
+ JSON.stringify(recallItems),
5033
+ BigInt(Math.floor(Date.now() / 1000)),
5034
+ );
7088
5035
 
7089
5036
  return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
7090
5037
  }
@@ -7125,7 +5072,7 @@ const plugin = {
7125
5072
 
7126
5073
  // 5. Decrypt candidates (text + embeddings) and build reranker input.
7127
5074
  const rerankerCandidates: RerankerCandidate[] = [];
7128
- const hookMetaMap = new Map<string, { importance: number; age: string }>();
5075
+ const hookMetaMap = new Map<string, { importance: number; age: string; category?: string }>();
7129
5076
 
7130
5077
  for (const candidate of candidates) {
7131
5078
  try {
@@ -7174,21 +5121,24 @@ const plugin = {
7174
5121
  rerankerCandidates,
7175
5122
  8,
7176
5123
  INTENT_WEIGHTS[srvHookIntent],
7177
- /* applySourceWeights (Retrieval v2 Tier 1) */ true,
5124
+ /* applySourceWeights (Retrieval v2 Tier 1) */ false,
7178
5125
  );
7179
5126
 
7180
5127
  if (reranked.length === 0) return undefined;
7181
5128
 
7182
5129
  // Relevance gate removed in rc.22 (see recall tool comment).
7183
5130
 
7184
- // 7. Build context string.
7185
- const lines = reranked.map((m, i) => {
7186
- const meta = hookMetaMap.get(m.id);
7187
- const importance = meta?.importance ?? 5;
7188
- const age = meta?.age ?? '';
7189
- return `${i + 1}. ${m.text} (importance: ${importance}/10, ${age})`;
7190
- });
7191
- const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
5131
+ // 7. Build context string using core's unified recall formatter (adds dates + header).
5132
+ // Server mode has no category metadata, so we use 'claim' as default.
5133
+ const srvRecallItems = reranked.map((m) => ({
5134
+ category: 'claim',
5135
+ text: m.text,
5136
+ created_at: m.createdAt ?? 0,
5137
+ }));
5138
+ const contextString = getSmartImportWasm().formatRecallContext(
5139
+ JSON.stringify(srvRecallItems),
5140
+ BigInt(Math.floor(Date.now() / 1000)),
5141
+ );
7192
5142
 
7193
5143
  return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
7194
5144
  } catch (err: unknown) {
@@ -7314,6 +5264,14 @@ const plugin = {
7314
5264
  // surface (scanner constraint: a single file may not contain both
7315
5265
  // fs.read* AND outbound-request trigger words). Deps are passed in
7316
5266
  // here with neutral aliases for the same reason.
5267
+ //
5268
+ // Lifecycle (rc.20, #402): register() can run more than once per process
5269
+ // (OpenClaw's SIGUSR1 restarts are IN-PROCESS, so the module cache and any
5270
+ // running poller survive). No guard is needed here — startTrajectoryPoller
5271
+ // holds a module-global singleton and stops the previous poller before
5272
+ // starting a new one, and each tick self-terminates if the plugin's own
5273
+ // module file is gone (uninstalled/replaced). This prevents the poller
5274
+ // accumulation + zombie-old-version submitters seen on pop-os.
7317
5275
  // ---------------------------------------------------------------
7318
5276
 
7319
5277
  startTrajectoryPoller({
@@ -7459,61 +5417,98 @@ const plugin = {
7459
5417
  );
7460
5418
 
7461
5419
  // ---------------------------------------------------------------
7462
- // 3.3.2-rc.1 (issue #186) — write `.loaded.json` manifest
5420
+ // OpenClaw native memory integration (Task 2.7) — register TR as the
5421
+ // memory backend: the MemoryPluginCapability + the memory_search /
5422
+ // memory_get tools the active-memory sub-agent drives.
7463
5423
  // ---------------------------------------------------------------
7464
5424
  //
7465
- // Final step of register(): drop the success manifest so the agent
7466
- // can `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` to
7467
- // verify which tools bound. Synchronous (see writePluginManifest doc).
7468
- // Never throws diagnostic loss only on I/O failure.
7469
- if (_pluginDirForManifest) {
7470
- try {
7471
- const ok = writePluginManifest(_pluginDirForManifest, {
7472
- loadedAt: Date.now(),
7473
- tools: _registeredToolNames.slice(),
7474
- version: pluginVersion ?? 'unknown',
7475
- // 3.3.8-rc.1 hybrid mode annotation: tools ARE registered with the
7476
- // SDK (required for plugin loader validation), but tool calls are
7477
- // dead-letter on OC 2026.5.2 due to issue #223. Use `tr <cmd>` CLI.
7478
- hybridMode: true,
7479
- hybridCliTools: ['tr status', 'tr pair', 'tr remember', 'tr recall'],
7480
- });
7481
- if (ok) {
7482
- api.logger.info(
7483
- `TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools + hybridCli=tr, version=${pluginVersion ?? 'unknown'})`,
7484
- );
7485
- }
7486
- } catch {
7487
- // Best-effort; helper swallows internally too.
7488
- }
5425
+ // This is THE integration point. For TR to BE the memory backend (not
5426
+ // just a tool plugin), it must register all four against TR's own
5427
+ // pipeline:
5428
+ // 1. api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })
5429
+ // 2. api.registerTool(() => createMemorySearchTool(runtime), { names: ['memory_search'] })
5430
+ // 3. api.registerTool(() => createMemoryGetTool(runtime), { names: ['memory_get'] })
5431
+ //
5432
+ // These registerTool calls go through the real host api.registerTool
5433
+ // directly (the 3.3.2-rc.1 monkey-patch + .loaded.json manifest
5434
+ // machinery were removed in Phase 3 — the conventional names survive
5435
+ // the tool-policy strip in OC 2026.5.x, so the declare-and-dead-letter
5436
+ // dance + manifest are obsolete).
5437
+ //
5438
+ // Deps: buildRecallDeps captures `api.logger` so the closures can call
5439
+ // ensureInitialized lazily on first use. The paired-account context
5440
+ // (authKeyHex / encryptionKey / userId / subgraphOwner) is NOT
5441
+ // resolved here — it's populated by initialize() on the first tool
5442
+ // call. The closures call ensureInitialized internally (see
5443
+ // buildRecallDeps docstring).
5444
+ //
5445
+ // Scanner note: this call is fine inside register() because
5446
+ // register() itself is not scanner-clean (the file pairs config reads
5447
+ // with network calls legitimately). The scanner-clean surface is
5448
+ // native-memory.ts (the wiring helper), which never touches env or
5449
+ // net primitives.
5450
+ //
5451
+ // Graceful degradation: the wiring is wrapped in try/catch so a
5452
+ // failure in the native memory pipeline cannot block plugin load.
5453
+ // NOTE (Phase 3.3): the legacy totalreclaw_* agent tools that used to
5454
+ // serve as the capture fallback were RETIRED in Task 3.2. If this
5455
+ // registration fails, the agent has NO memory surface until the cause
5456
+ // is fixed and the gateway restarted. The before_tool_call gate stays
5457
+ // armed (memory_search/memory_get are simply never registered), and
5458
+ // auto-extraction hooks still fire on the message_received / agent_end
5459
+ // cadence — they write to the subgraph directly, so memories keep
5460
+ // getting captured even if the agent can't read them mid-session.
5461
+ try {
5462
+ registerNativeMemory(api, buildRecallDeps(api.logger));
5463
+ api.logger.info('TotalReclaw: registered native MemoryPluginCapability + memory_search/memory_get tools');
5464
+ } catch (err: unknown) {
5465
+ const msg = err instanceof Error ? err.message : String(err);
5466
+ api.logger.warn(
5467
+ `TotalReclaw: native memory capability registration failed — agent memory_search/memory_get UNAVAILABLE until fixed: ${msg}`,
5468
+ );
7489
5469
  }
5470
+
5471
+ // ---------------------------------------------------------------
5472
+ // Skill auto-register (rc.17 QA finding: plugin installs but the
5473
+ // SKILL.md playbook does not — agents skipped the separate
5474
+ // `openclaw skills install totalreclaw` step and ended up without
5475
+ // pairing / recall instructions). Mirror the bundled SKILL.md +
5476
+ // skill.json from the package root into the workspace skills dir so
5477
+ // OpenClaw's workspace skill scanner discovers them on the next
5478
+ // gateway load. A single `openclaw plugins install` is now enough
5479
+ // for both plugin + skill. Idempotent + never throws (see
5480
+ // skill-register.ts). Lives in a scanner-clean helper because
5481
+ // index.ts already pairs env-derived config with network calls, so
5482
+ // the disk I/O must stay out of this file.
5483
+ // ---------------------------------------------------------------
5484
+ try {
5485
+ // Re-resolve the dist dir here: the earlier `pluginDir` const
5486
+ // lives inside its own inner try/catch scope and is not visible
5487
+ // this far down. The call is pure + cheap (URL parse + dirname).
5488
+ ensureSkillRegistered({
5489
+ pluginDir: nodePath.dirname(fileURLToPath(import.meta.url)),
5490
+ skillsDir: nodePath.join(CONFIG.openclawWorkspace, 'skills'),
5491
+ logger: api.logger,
5492
+ });
5493
+ } catch (err: unknown) {
5494
+ const msg = err instanceof Error ? err.message : String(err);
5495
+ api.logger.warn(`TotalReclaw: skill auto-register failed (non-fatal): ${msg}`);
5496
+ }
5497
+
7490
5498
  } catch (registerErr: unknown) {
7491
5499
  // ---------------------------------------------------------------
7492
- // 3.3.2-rc.1 (issue #186) — write `.error.json` on register() throw
5500
+ // register() threw best-effort log then re-throw so the SDK sees
5501
+ // the original failure. (3.3.2-rc.1 used to write a `.error.json`
5502
+ // marker here; that machinery was retired in Phase 3.4 along with
5503
+ // the `.loaded.json` success manifest. The gateway log is now the
5504
+ // source of truth for register() failures.)
7493
5505
  // ---------------------------------------------------------------
7494
- //
7495
- // Some surface threw out of register(). Drop a structured error
7496
- // marker the agent can grep. Best-effort logging then re-throw so
7497
- // the SDK sees the original failure.
7498
5506
  const errMsg = registerErr instanceof Error ? registerErr.message : String(registerErr);
7499
- const errStack = registerErr instanceof Error ? registerErr.stack : undefined;
7500
5507
  try {
7501
5508
  api.logger.error(`TotalReclaw: register() threw: ${errMsg}`);
7502
5509
  } catch {
7503
5510
  // Logger may be unavailable (very early failure path).
7504
5511
  }
7505
- if (_pluginDirForManifest) {
7506
- try {
7507
- writePluginError(_pluginDirForManifest, {
7508
- loadedAt: Date.now(),
7509
- error: errMsg,
7510
- stack: errStack,
7511
- version: 'unknown',
7512
- });
7513
- } catch {
7514
- // Best-effort.
7515
- }
7516
- }
7517
5512
  throw registerErr;
7518
5513
  }
7519
5514
  },