@totalreclaw/totalreclaw 3.3.7-rc.2 → 3.3.8-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to `@totalreclaw/totalreclaw` (the OpenClaw plugin) are docu
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.3.7-rc.3] — 2026-05-04
8
+
9
+ Root cause of tool-binding gap on OpenClaw 2026.5.2: the plugin manifest (`openclaw.plugin.json`) was missing the `contracts.tools` declaration that OpenClaw 2026.5.2 now requires before any non-bundled plugin may register agent tools. Without it, every `api.registerTool()` call is silently rejected — `openclaw plugins doctor` shows 17× "plugin must declare contracts.tools before registering agent tools". The gateway loads the plugin module (ESM import succeeds, `plugins.allow` check passes) but no tools bind to any session, so `totalreclaw_pair` / `totalreclaw_remember` / etc. are invisible to the agent and auto-QA step 1 fails.
10
+
11
+ The `plugins.allow` warning ("plugins.allow is empty; discovered non-bundled plugins may auto-load: totalreclaw") is a separate UX issue logged by the discoveryLoader when `plugins.allow` is absent — it is informational and does NOT prevent loading; the actual block is `contracts.tools`. Setting `plugins.allow=["totalreclaw"]` via `openclaw config set` is still recommended to suppress the noisy warning for users who follow the install guide, but it is not the root fix. See NOTE below.
12
+
13
+ ### Fixed — add `contracts.tools` to plugin manifest (issue #87 / FR #72097 tool-binding race)
14
+
15
+ **Root cause:** OpenClaw 2026.5.2 (image `openclaw-qa:2026.5.2`) enforced a new security gate: non-bundled plugins must declare `contracts.tools` — an explicit allowlist of tool names — in their `openclaw.plugin.json` before registering any agent tool. Plugins that omit the field have every `registerTool` call silently dropped. The gate did not exist in ≤2026.4.24 (the version the plugin was built against), so the gap only surfaced when the QA container was upgraded.
16
+
17
+ **Fix:** added `contracts.tools` to `skill/plugin/openclaw.plugin.json` listing all 17 tool names the plugin registers:
18
+ `totalreclaw_remember`, `totalreclaw_recall`, `totalreclaw_forget`, `totalreclaw_export`, `totalreclaw_status`, `totalreclaw_preload_embedder`, `totalreclaw_consolidate`, `totalreclaw_pin`, `totalreclaw_unpin`, `totalreclaw_retype`, `totalreclaw_set_scope`, `totalreclaw_import_from`, `totalreclaw_import_batch`, `totalreclaw_upgrade`, `totalreclaw_migrate`, `totalreclaw_pair`, `totalreclaw_report_qa_bug`.
19
+
20
+ **NOTE — `plugins.allow` side-effect:** OpenClaw 2026.5.2 also emits `plugins.allow is empty; discovered non-bundled plugins may auto-load: totalreclaw` as an INFO log whenever a session starts and `plugins.allow` is unset. The plugin install step (`openclaw plugins install @totalreclaw/totalreclaw@<ver>`) does NOT automatically add the plugin to `plugins.allow`. As a consequence, users who follow the public install guide hit this noisy warning every session. The `contracts.tools` fix resolves tool binding; the `plugins.allow` warning requires either (a) an upstream OpenClaw change to auto-populate `plugins.allow` on `plugins install`, or (b) adding `openclaw config set plugins.allow '["totalreclaw"]'` to the install guide as a one-time post-install step. Tracked as FR upstream; the setup guide has been updated to include the config set step.
21
+
7
22
  ## [3.3.7-rc.2] — 2026-05-04
8
23
 
9
24
  Follow-up to 3.3.7-rc.1, caught in Pedro's manual integration testing on the OpenClaw side 2026-05-03. The 5-tier auth fix shipped DEAD-CODE in rc.1: gateway logs surfaced `[gateway] [plugins] command registration failed: Command name "restart" is reserved by a built-in command (plugin=totalreclaw, source=/home/pdiogo/.openclaw/extensions/totalreclaw/dist/index.js)`. The plugin's `/restart` registration was rejected because OpenClaw's plugin registry hard-rejects `restart` (and the rest of `RESERVED_COMMANDS` — see `node_modules/openclaw/dist/registry-*.js` — `help`, `commands`, `status`, `whoami`, `context`, `stop`, `restart`, `reset`, `new`, `compact`, `config`, `debug`, `allowlist`, `activation`, `skill`, `subagents`, `kill`, `steer`, `tell`, `model`, `models`, `queue`, `send`, `bash`, `exec`, `think`, `verbose`, `reasoning`, `elevated`, `usage`). Built-in `/restart` retains its allow-from-only semantics, which is what gave Pedro the original "You are not authorized" — and our 5-tier fallback never ran because plugin registration never succeeded.
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
3
  description: "Install + set up TotalReclaw encrypted memory for OpenClaw, then use totalreclaw_remember / totalreclaw_recall. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', or any remember / recall request."
4
- version: 3.3.7-rc.2
4
+ version: 3.3.8-rc.1
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
@@ -119,9 +119,48 @@ NEVER echo / generate / ask the user to paste a recovery phrase in chat. NEVER i
119
119
  - "status" → `totalreclaw_status`. "upgrade" → `totalreclaw_upgrade` (paste Stripe URL verbatim). Post-upgrade testnet → mainnet: `totalreclaw_migrate` with `dry_run` first.
120
120
  - "import / export / consolidate" → `totalreclaw_import_from` (`dry_run=true` first; `_import_batch` for >50 chunks) · `totalreclaw_export` · `totalreclaw_consolidate` (`dry_run` first).
121
121
 
122
+ ## OpenClaw 2026.5.2 hybrid mode (issue #223 workaround — plugin 3.3.8-rc.1+)
123
+
124
+ OpenClaw 2026.5.2 has a tool-policy-pipeline bug (issue #223) that strips non-bundled plugin tools before they reach the agent toolset. Plugin 3.3.8-rc.1 ships a hybrid workaround:
125
+
126
+ - **Hooks still fire**: `before_agent_start`, `agent_end`, `message_received`, `before_reset` use a separate code path not affected by issue #223. Auto-extraction, digest injection, preloading, and billing checks all work normally.
127
+ - **Tools replaced by `tr` CLI**: instead of `totalreclaw_remember`, `totalreclaw_recall`, etc., use the `tr` binary installed with the plugin. The agent runs it via shell tool.
128
+
129
+ **Agent usage under hybrid mode** (check `tr status` output for `hybrid-mode: yes`):
130
+
131
+ ```
132
+ # remember a fact
133
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js remember "I work at Graph Foundation"
134
+ # → outputs {"ok":true,"id":"...","text":"..."}
135
+
136
+ # recall facts
137
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js recall "where does the user work"
138
+ # → outputs {"ok":true,"count":1,"query":"...","memories":[...]}
139
+
140
+ # status check
141
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status
142
+ ```
143
+
144
+ Note: the `tr` bin name conflicts with the system `tr` utility; always invoke via full path `node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js <cmd>` or the gateway host's equivalent path.
145
+
146
+ **Required post-install config** (add to `~/.openclaw/openclaw.json`):
147
+ ```json
148
+ {
149
+ "plugins": {
150
+ "entries": {
151
+ "totalreclaw": {
152
+ "hooks": { "allowConversationAccess": true }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ This unlocks typed hooks (`agent_end`, etc.) for non-bundled plugins. Without it, auto-extraction still works (extraction runs in `message_received`) but the `agent_end` hook that writes the digest is silently blocked.
160
+
122
161
  ## Diagnostics
123
162
 
124
- - `totalreclaw_*` tools not visible → plugin not installed or auto-reload didn't fire. Verify via `openclaw plugins list | grep totalreclaw` (or, on a managed service that doesn't expose the CLI, ask the user to confirm via their service's plugins UI). If `totalreclaw` is listed but tools are still absent, the gateway probably hasn't completed its auto-restart yet (give it 5-10s) OR `gateway.reload.mode = off` is set — instruct manual restart as fallback: `openclaw gateway restart` (native), `docker restart <your-container-name>` (Docker self-host), or the managed service's restart control. If the plugin is listed and the gateway has been restarted but tools are still missing (container-agent gap, issue #87), shell out to `openclaw totalreclaw pair generate --url-pin-only` on the gateway host and hand the user the `url` + `pin` from the JSON payload — OR `openclaw totalreclaw onboard --pair-only` (same flow, returns `pair_url` instead of `url`, issue #95). If your shell doesn't reach the gateway host (managed service), tell the user to install/enable the plugin via their service's plugins UI and restart the gateway via the service's restart control. Do NOT suggest the bare `openclaw totalreclaw onboard` (leaks phrase on stdout — deprecated in rc.18, removed next RC).
163
+ - `totalreclaw_*` tools not visible → plugin not installed or auto-reload didn't fire. Verify via `openclaw plugins list | grep totalreclaw` (or, on a managed service that doesn't expose the CLI, ask the user to confirm via their service's plugins UI). If `totalreclaw` is listed but tools are still absent, the gateway probably hasn't completed its auto-restart yet (give it 5-10s) OR `gateway.reload.mode = off` is set — instruct manual restart as fallback: `openclaw gateway restart` (native), `docker restart <your-container-name>` (Docker self-host), or the managed service's restart control. If the plugin is listed and the gateway has been restarted but tools are still missing (container-agent gap, issue #87 / OC 2026.5.2 issue #223), switch to hybrid mode: use `node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js <cmd>` for remember/recall/pair/status. If your shell doesn't reach the gateway host (managed service), tell the user to install/enable the plugin via their service's plugins UI and restart the gateway via the service's restart control. Do NOT suggest the bare `openclaw totalreclaw onboard` (leaks phrase on stdout — deprecated in rc.18, removed next RC).
125
164
  - User says done but `credentials.json` missing → PIN expired or entered wrong phrase; call `totalreclaw_pair` again.
126
165
  - `onboarding required` → credentials missing; redo from the account-setup step.
127
166
  - `quota exceeded` → `totalreclaw_status`, then offer `totalreclaw_upgrade`.
package/dist/index.js CHANGED
@@ -2358,6 +2358,29 @@ const plugin = {
2358
2358
  // write would race that freeze.
2359
2359
  const _registeredToolNames = [];
2360
2360
  const _originalRegisterTool = api.registerTool.bind(api);
2361
+ // 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
2362
+ // The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
2363
+ // before they reach the agent's session toolset. registerTool() calls
2364
+ // succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
2365
+ // But tool calls never reach execute() — the pipeline discards them before
2366
+ // the agent's toolset is built.
2367
+ //
2368
+ // Strategy: keep all registerTool() calls intact so the plugin loader can
2369
+ // verify the contracts.tools declaration and load the plugin (hooks fire).
2370
+ // The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
2371
+ // path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
2372
+ // are dead-letter but hooks (before_agent_start, agent_end, message_received,
2373
+ // before_reset) still fire via the unbroken hook code path.
2374
+ //
2375
+ // NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
2376
+ // contracts.tools declaration against registered tools at load time and
2377
+ // drops the plugin (unloads it) if no tools match. Confirmed empirically:
2378
+ // no-op'ing registerTool causes the gateway to log "4 plugins" instead of
2379
+ // "5 plugins" after restart (plugin excluded from active set).
2380
+ //
2381
+ // TODO: when OC ships a fix for issue #223, restore tool-call routing
2382
+ // and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
2383
+ // can stay as a convenience CLI regardless.
2361
2384
  api.registerTool = (tool, opts) => {
2362
2385
  try {
2363
2386
  const t = tool;
@@ -5932,9 +5955,14 @@ const plugin = {
5932
5955
  loadedAt: Date.now(),
5933
5956
  tools: _registeredToolNames.slice(),
5934
5957
  version: pluginVersion ?? 'unknown',
5958
+ // 3.3.8-rc.1 hybrid mode annotation: tools ARE registered with the
5959
+ // SDK (required for plugin loader validation), but tool calls are
5960
+ // dead-letter on OC 2026.5.2 due to issue #223. Use `tr <cmd>` CLI.
5961
+ hybridMode: true,
5962
+ hybridCliTools: ['tr status', 'tr pair', 'tr remember', 'tr recall'],
5935
5963
  });
5936
5964
  if (ok) {
5937
- api.logger.info(`TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools, version=${pluginVersion ?? 'unknown'})`);
5965
+ api.logger.info(`TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools + hybridCli=tr, version=${pluginVersion ?? 'unknown'})`);
5938
5966
  }
5939
5967
  }
5940
5968
  catch {
package/dist/tr-cli.js ADDED
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tr — TotalReclaw hybrid CLI (3.3.8-rc.1 workaround for OpenClaw 2026.5.2 issue #223)
4
+ *
5
+ * OpenClaw 2026.5.2 has a tool-policy-pipeline bug that strips non-bundled plugin tools
6
+ * before they reach the agent toolset. This CLI bypasses the broken tool-registration
7
+ * path entirely. The agent runs `tr <cmd>` from shell; the plugin keeps its hooks
8
+ * (before_agent_start, agent_end, message_received) via the unbroken hook code path.
9
+ *
10
+ * Phrase-safety: this CLI reads credentials.json (mnemonic at rest) but NEVER
11
+ * prints the mnemonic to stdout, stderr, or any log. Phrase only enters via QR-pair
12
+ * browser tier (pair-cli.ts / pair-cli-relay.ts — unchanged).
13
+ *
14
+ * Commands:
15
+ * tr status — print onboarding + credentials state
16
+ * tr pair [--json] — start a relay pairing session, print URL+PIN+QR
17
+ * tr remember <text> — store a memory in the encrypted vault
18
+ * tr recall <query> — search the encrypted vault, print results as JSON
19
+ *
20
+ * Install: wired via package.json `bin.tr` → dist/tr-cli.js
21
+ * Usage from container: `docker exec tr-openclaw tr status`
22
+ */
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+ import { randomUUID } from 'node:crypto';
26
+ import { CONFIG } from './config.js';
27
+ import { loadCredentialsJson } from './fs-helpers.js';
28
+ import { printStatus } from './onboarding-cli.js';
29
+ import { deriveKeys, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
30
+ import { createApiClient } from './api-client.js';
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+ const CREDENTIALS_PATH = CONFIG.credentialsPath;
35
+ const SERVER_URL = CONFIG.serverUrl;
36
+ const STATE_PATH = CONFIG.onboardingStatePath;
37
+ function die(msg, code = 1) {
38
+ process.stderr.write(`tr: ${msg}\n`);
39
+ process.exit(code);
40
+ }
41
+ function log(msg) {
42
+ process.stdout.write(msg + '\n');
43
+ }
44
+ async function buildContext() {
45
+ const creds = loadCredentialsJson(CREDENTIALS_PATH);
46
+ if (!creds) {
47
+ die('TotalReclaw is not set up. Run: openclaw totalreclaw onboard\n(or: tr pair)');
48
+ }
49
+ const mnemonic = (typeof creds.mnemonic === 'string' && creds.mnemonic.trim()) ||
50
+ (typeof creds.recovery_phrase === 'string' && creds.recovery_phrase.trim()) ||
51
+ '';
52
+ if (!mnemonic) {
53
+ die('No recovery phrase in credentials.json. Run: openclaw totalreclaw onboard');
54
+ }
55
+ // Parse existing salt/userId from credentials.json
56
+ let existingSalt;
57
+ let existingUserId;
58
+ const saltStr = typeof creds.salt === 'string' ? creds.salt : undefined;
59
+ if (saltStr) {
60
+ if (/^[0-9a-f]{64}$/i.test(saltStr)) {
61
+ existingSalt = Buffer.from(saltStr, 'hex');
62
+ }
63
+ else {
64
+ existingSalt = Buffer.from(saltStr, 'base64');
65
+ }
66
+ }
67
+ existingUserId = typeof creds.userId === 'string' ? creds.userId : undefined;
68
+ const keys = deriveKeys(mnemonic, existingSalt);
69
+ const authKeyHex = keys.authKey.toString('hex');
70
+ const apiClient = createApiClient(SERVER_URL);
71
+ let userId;
72
+ if (existingUserId) {
73
+ userId = existingUserId;
74
+ }
75
+ else {
76
+ // Register to get userId (idempotent on relay)
77
+ const authHash = computeAuthKeyHash(keys.authKey);
78
+ const saltHex = keys.salt.toString('hex');
79
+ try {
80
+ const result = await apiClient.register(authHash, saltHex);
81
+ userId = result.user_id;
82
+ }
83
+ catch (err) {
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ if (msg.includes('USER_EXISTS')) {
86
+ userId = authHash.slice(0, 32);
87
+ }
88
+ else {
89
+ die(`Relay registration failed: ${msg}`);
90
+ }
91
+ }
92
+ }
93
+ return {
94
+ authKeyHex,
95
+ encryptionKey: keys.encryptionKey,
96
+ dedupKey: keys.dedupKey,
97
+ apiClient,
98
+ userId,
99
+ };
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Command: status
103
+ // ---------------------------------------------------------------------------
104
+ async function cmdStatus() {
105
+ // Print onboarding + credentials state (never prints mnemonic — same as
106
+ // the `openclaw totalreclaw status` subcommand surface).
107
+ printStatus(CREDENTIALS_PATH, STATE_PATH, process.stdout);
108
+ // Additional: loaded.json check to confirm plugin hooks are active.
109
+ // Reads manifest written by register() in index.ts.
110
+ // Probe both install paths: extensions/ (local tgz installs) and npm/ (registry installs).
111
+ try {
112
+ const fs = await import('node:fs');
113
+ const candidatePaths = [
114
+ // extensions-path (local tgz / --force install) — .loaded.json sits at root, not dist/
115
+ path.join(os.homedir(), '.openclaw', 'extensions', 'totalreclaw', '.loaded.json'),
116
+ // npm-path (registry install) — .loaded.json inside dist/
117
+ path.join(os.homedir(), '.openclaw', 'npm', 'node_modules', '@totalreclaw', 'totalreclaw', 'dist', '.loaded.json'),
118
+ ];
119
+ const resolvedPath = candidatePaths.find((p) => fs.existsSync(p));
120
+ if (resolvedPath) {
121
+ const raw = fs.readFileSync(resolvedPath, 'utf-8');
122
+ const manifest = JSON.parse(raw);
123
+ const ageMs = Date.now() - (manifest.loadedAt ?? 0);
124
+ const ageSec = Math.round(ageMs / 1000);
125
+ process.stdout.write(`\n plugin: loaded (version=${manifest.version ?? '?'} bootCount=${manifest.bootCount ?? '?'} loaded=${ageSec}s ago)\n` +
126
+ ` hybrid-mode: ${manifest.hybridMode ? 'yes (use tr <cmd>)' : 'no'}\n` +
127
+ ` hooks: before_agent_start, agent_end, message_received, before_reset\n` +
128
+ ` note: tools from .loaded.json are STRIPPED by OC 2026.5.2 issue #223;\n` +
129
+ ` use \`tr <cmd>\` from shell instead\n`);
130
+ }
131
+ else {
132
+ process.stdout.write('\n plugin: .loaded.json not found — plugin may not be loaded\n');
133
+ }
134
+ }
135
+ catch {
136
+ // Best-effort
137
+ }
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Command: pair
141
+ // ---------------------------------------------------------------------------
142
+ async function cmdPair(args) {
143
+ // Delegate to the existing pair-cli-relay.ts via a thin wrapper.
144
+ // The pair flow is relay-brokered (works through Docker NAT).
145
+ // Phrase-safety: pair-cli-relay.ts is x25519-only; mnemonic never appears.
146
+ const outputMode = args.includes('--json') ? 'json' : args.includes('--url-pin') ? 'url-pin' : 'human';
147
+ const { runRelayPairCli } = await import('./pair-cli-relay.js');
148
+ const { defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
149
+ const io = buildDefaultPairCliIo();
150
+ const outcome = await runRelayPairCli('generate', {
151
+ relayBaseUrl: CONFIG.pairRelayUrl,
152
+ credentialsPath: CREDENTIALS_PATH,
153
+ onboardingStatePath: STATE_PATH,
154
+ logger: {
155
+ info: (m) => process.stderr.write(`[info] ${m}\n`),
156
+ warn: (m) => process.stderr.write(`[warn] ${m}\n`),
157
+ error: (m) => process.stderr.write(`[error] ${m}\n`),
158
+ },
159
+ pluginVersion: '3.3.8-rc.1',
160
+ deriveScopeAddress: undefined,
161
+ renderQr: defaultRenderQr,
162
+ io,
163
+ outputMode: outputMode,
164
+ });
165
+ if (outcome.status !== 'completed' && outcome.status !== 'canceled') {
166
+ die(`Pairing ${outcome.status}`, 1);
167
+ }
168
+ if (outcome.status === 'canceled') {
169
+ process.exit(130);
170
+ }
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Command: remember
174
+ // ---------------------------------------------------------------------------
175
+ async function cmdRemember(args) {
176
+ const text = args.join(' ').trim();
177
+ if (!text) {
178
+ die('Usage: tr remember <text>');
179
+ }
180
+ const ctx = await buildContext();
181
+ // Build a minimal MemoryTaxonomy v1 claim blob (same format as storeExtractedFacts)
182
+ const now = new Date().toISOString();
183
+ const factId = randomUUID().replace(/-/g, '');
184
+ // Encrypt the memory text
185
+ const blob = JSON.stringify({
186
+ text,
187
+ type: 'claim',
188
+ source: 'user',
189
+ scope: 'unspecified',
190
+ importance: 8,
191
+ metadata: {
192
+ type: 'claim',
193
+ source: 'user',
194
+ scope: 'unspecified',
195
+ importance: 8,
196
+ },
197
+ timestamp: now,
198
+ version: 'v1',
199
+ });
200
+ const encrypted_blob = encrypt(blob, ctx.encryptionKey);
201
+ const blind_indices = generateBlindIndices(text);
202
+ const content_fp = generateContentFingerprint(text, ctx.dedupKey);
203
+ const payload = {
204
+ id: factId,
205
+ timestamp: now,
206
+ encrypted_blob,
207
+ blind_indices,
208
+ decay_score: 8,
209
+ source: 'cli:tr-remember',
210
+ content_fp,
211
+ };
212
+ try {
213
+ await ctx.apiClient.store(ctx.userId, [payload], ctx.authKeyHex);
214
+ log(JSON.stringify({ ok: true, id: factId, text }));
215
+ }
216
+ catch (err) {
217
+ const msg = err instanceof Error ? err.message : String(err);
218
+ die(`remember failed: ${msg}`);
219
+ }
220
+ }
221
+ // ---------------------------------------------------------------------------
222
+ // Command: recall
223
+ // ---------------------------------------------------------------------------
224
+ async function cmdRecall(args) {
225
+ const query = args.join(' ').trim();
226
+ if (!query) {
227
+ die('Usage: tr recall <query>');
228
+ }
229
+ const ctx = await buildContext();
230
+ // Generate word trapdoors for blind search
231
+ const trapdoors = generateBlindIndices(query);
232
+ if (trapdoors.length === 0) {
233
+ log(JSON.stringify({ ok: true, count: 0, memories: [] }));
234
+ return;
235
+ }
236
+ try {
237
+ const candidates = await ctx.apiClient.search(ctx.userId, trapdoors, 12, ctx.authKeyHex);
238
+ const memories = [];
239
+ for (const c of candidates) {
240
+ try {
241
+ const raw = decrypt(c.encrypted_blob, ctx.encryptionKey);
242
+ const parsed = JSON.parse(raw);
243
+ if (parsed.text) {
244
+ memories.push({
245
+ id: c.fact_id,
246
+ text: parsed.text,
247
+ score: c.decay_score,
248
+ timestamp: new Date(c.timestamp).toISOString(),
249
+ });
250
+ }
251
+ }
252
+ catch {
253
+ // Skip undecryptable
254
+ }
255
+ }
256
+ // Simple relevance sort by decay_score (descending)
257
+ memories.sort((a, b) => b.score - a.score);
258
+ log(JSON.stringify({ ok: true, count: memories.length, query, memories }));
259
+ }
260
+ catch (err) {
261
+ const msg = err instanceof Error ? err.message : String(err);
262
+ die(`recall failed: ${msg}`);
263
+ }
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // Dispatch
267
+ // ---------------------------------------------------------------------------
268
+ async function main() {
269
+ const args = process.argv.slice(2);
270
+ const cmd = args[0];
271
+ switch (cmd) {
272
+ case 'status':
273
+ await cmdStatus();
274
+ break;
275
+ case 'pair':
276
+ await cmdPair(args.slice(1));
277
+ break;
278
+ case 'remember':
279
+ await cmdRemember(args.slice(1));
280
+ break;
281
+ case 'recall':
282
+ await cmdRecall(args.slice(1));
283
+ break;
284
+ case undefined:
285
+ case '--help':
286
+ case '-h':
287
+ process.stdout.write('TotalReclaw hybrid CLI (OpenClaw 2026.5.2 issue #223 workaround)\n\n' +
288
+ 'Usage:\n' +
289
+ ' tr status — onboarding + plugin load state\n' +
290
+ ' tr pair [--json] — start a relay pairing session\n' +
291
+ ' tr remember <text> — store a memory\n' +
292
+ ' tr recall <query> — search memories (outputs JSON)\n\n' +
293
+ 'Environment:\n' +
294
+ ' TOTALRECLAW_SERVER_URL — relay URL (default: api-staging.totalreclaw.xyz)\n' +
295
+ ' TOTALRECLAW_CREDENTIALS_PATH — override credentials.json path\n');
296
+ break;
297
+ default:
298
+ die(`Unknown command: ${cmd}. Run \`tr --help\` for usage.`);
299
+ }
300
+ }
301
+ main().catch((err) => {
302
+ const msg = err instanceof Error ? err.message : String(err);
303
+ process.stderr.write(`tr: fatal: ${msg}\n`);
304
+ process.exit(2);
305
+ });
package/fs-helpers.ts CHANGED
@@ -594,6 +594,18 @@ export interface PluginLoadManifest {
594
594
  * verify the manifest is from the currently-running container vs a
595
595
  * stale-mounted copy. */
596
596
  pid?: number;
597
+ /**
598
+ * 3.3.8-rc.1 — true when registerTool() calls are no-op'd due to the
599
+ * OC 2026.5.2 issue #223 hybrid workaround. Tools in `tools[]` are
600
+ * exposed via the `tr` CLI binary instead of via the plugin API.
601
+ */
602
+ hybridMode?: boolean;
603
+ /**
604
+ * 3.3.8-rc.1 — CLI commands that replace the tool registrations
605
+ * when hybridMode=true. Agent runs these from shell instead of using
606
+ * tool calls.
607
+ */
608
+ hybridCliTools?: string[];
597
609
  }
598
610
 
599
611
  /** Schema written to `.error.json` when register() throws. */
package/index.ts CHANGED
@@ -2926,6 +2926,29 @@ const plugin = {
2926
2926
  // write would race that freeze.
2927
2927
  const _registeredToolNames: string[] = [];
2928
2928
  const _originalRegisterTool = api.registerTool.bind(api);
2929
+ // 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
2930
+ // The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
2931
+ // before they reach the agent's session toolset. registerTool() calls
2932
+ // succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
2933
+ // But tool calls never reach execute() — the pipeline discards them before
2934
+ // the agent's toolset is built.
2935
+ //
2936
+ // Strategy: keep all registerTool() calls intact so the plugin loader can
2937
+ // verify the contracts.tools declaration and load the plugin (hooks fire).
2938
+ // The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
2939
+ // path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
2940
+ // are dead-letter but hooks (before_agent_start, agent_end, message_received,
2941
+ // before_reset) still fire via the unbroken hook code path.
2942
+ //
2943
+ // NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
2944
+ // contracts.tools declaration against registered tools at load time and
2945
+ // drops the plugin (unloads it) if no tools match. Confirmed empirically:
2946
+ // no-op'ing registerTool causes the gateway to log "4 plugins" instead of
2947
+ // "5 plugins" after restart (plugin excluded from active set).
2948
+ //
2949
+ // TODO: when OC ships a fix for issue #223, restore tool-call routing
2950
+ // and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
2951
+ // can stay as a convenience CLI regardless.
2929
2952
  api.registerTool = (tool: unknown, opts?: { name?: string; names?: string[] }) => {
2930
2953
  try {
2931
2954
  const t = tool as { name?: unknown } | null | undefined;
@@ -6917,10 +6940,15 @@ const plugin = {
6917
6940
  loadedAt: Date.now(),
6918
6941
  tools: _registeredToolNames.slice(),
6919
6942
  version: pluginVersion ?? 'unknown',
6943
+ // 3.3.8-rc.1 hybrid mode annotation: tools ARE registered with the
6944
+ // SDK (required for plugin loader validation), but tool calls are
6945
+ // dead-letter on OC 2026.5.2 due to issue #223. Use `tr <cmd>` CLI.
6946
+ hybridMode: true,
6947
+ hybridCliTools: ['tr status', 'tr pair', 'tr remember', 'tr recall'],
6920
6948
  });
6921
6949
  if (ok) {
6922
6950
  api.logger.info(
6923
- `TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools, version=${pluginVersion ?? 'unknown'})`,
6951
+ `TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools + hybridCli=tr, version=${pluginVersion ?? 'unknown'})`,
6924
6952
  );
6925
6953
  }
6926
6954
  } catch {
@@ -2,6 +2,28 @@
2
2
  "id": "totalreclaw",
3
3
  "name": "TotalReclaw",
4
4
  "description": "End-to-end encrypted memory vault for AI agents",
5
+ "kind": "memory",
6
+ "contracts": {
7
+ "tools": [
8
+ "totalreclaw_remember",
9
+ "totalreclaw_recall",
10
+ "totalreclaw_forget",
11
+ "totalreclaw_export",
12
+ "totalreclaw_status",
13
+ "totalreclaw_preload_embedder",
14
+ "totalreclaw_consolidate",
15
+ "totalreclaw_pin",
16
+ "totalreclaw_unpin",
17
+ "totalreclaw_retype",
18
+ "totalreclaw_set_scope",
19
+ "totalreclaw_import_from",
20
+ "totalreclaw_import_batch",
21
+ "totalreclaw_upgrade",
22
+ "totalreclaw_migrate",
23
+ "totalreclaw_pair",
24
+ "totalreclaw_report_qa_bug"
25
+ ]
26
+ },
5
27
  "configSchema": {
6
28
  "type": "object",
7
29
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.7-rc.2",
3
+ "version": "3.3.8-rc.1",
4
4
  "description": "End-to-end encrypted, agent-portable memory for OpenClaw and any LLM-agent runtime. XChaCha20-Poly1305 with protobuf v4 + on-chain Memory Taxonomy v1 (claim / preference / directive / commitment / episode / summary).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -48,6 +48,9 @@
48
48
  },
49
49
  "main": "./dist/index.js",
50
50
  "types": "./dist/index.d.ts",
51
+ "bin": {
52
+ "tr": "./dist/tr-cli.js"
53
+ },
51
54
  "files": [
52
55
  "dist/",
53
56
  "*.ts",
package/skill.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totalreclaw",
3
- "version": "3.3.7-rc.2",
3
+ "version": "3.3.8-rc.1",
4
4
  "description": "End-to-end encrypted memory for AI agents — portable, yours forever. XChaCha20-Poly1305 E2EE: server never sees plaintext.",
5
5
  "author": "TotalReclaw Team",
6
6
  "license": "MIT",
package/tr-cli.ts ADDED
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tr — TotalReclaw hybrid CLI (3.3.8-rc.1 workaround for OpenClaw 2026.5.2 issue #223)
4
+ *
5
+ * OpenClaw 2026.5.2 has a tool-policy-pipeline bug that strips non-bundled plugin tools
6
+ * before they reach the agent toolset. This CLI bypasses the broken tool-registration
7
+ * path entirely. The agent runs `tr <cmd>` from shell; the plugin keeps its hooks
8
+ * (before_agent_start, agent_end, message_received) via the unbroken hook code path.
9
+ *
10
+ * Phrase-safety: this CLI reads credentials.json (mnemonic at rest) but NEVER
11
+ * prints the mnemonic to stdout, stderr, or any log. Phrase only enters via QR-pair
12
+ * browser tier (pair-cli.ts / pair-cli-relay.ts — unchanged).
13
+ *
14
+ * Commands:
15
+ * tr status — print onboarding + credentials state
16
+ * tr pair [--json] — start a relay pairing session, print URL+PIN+QR
17
+ * tr remember <text> — store a memory in the encrypted vault
18
+ * tr recall <query> — search the encrypted vault, print results as JSON
19
+ *
20
+ * Install: wired via package.json `bin.tr` → dist/tr-cli.js
21
+ * Usage from container: `docker exec tr-openclaw tr status`
22
+ */
23
+
24
+ import path from 'node:path';
25
+ import os from 'node:os';
26
+ import { randomUUID } from 'node:crypto';
27
+
28
+ import { CONFIG } from './config.js';
29
+ import { loadCredentialsJson } from './fs-helpers.js';
30
+ import { printStatus } from './onboarding-cli.js';
31
+ import {
32
+ deriveKeys,
33
+ computeAuthKeyHash,
34
+ encrypt,
35
+ decrypt,
36
+ generateBlindIndices,
37
+ generateContentFingerprint,
38
+ } from './crypto.js';
39
+ import { createApiClient } from './api-client.js';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const CREDENTIALS_PATH = CONFIG.credentialsPath;
46
+ const SERVER_URL = CONFIG.serverUrl;
47
+ const STATE_PATH = CONFIG.onboardingStatePath;
48
+
49
+ function die(msg: string, code = 1): never {
50
+ process.stderr.write(`tr: ${msg}\n`);
51
+ process.exit(code);
52
+ }
53
+
54
+ function log(msg: string): void {
55
+ process.stdout.write(msg + '\n');
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Core init — minimal version of index.ts initialize()
60
+ // ---------------------------------------------------------------------------
61
+
62
+ interface CliContext {
63
+ authKeyHex: string;
64
+ encryptionKey: Buffer;
65
+ dedupKey: Buffer;
66
+ apiClient: ReturnType<typeof createApiClient>;
67
+ userId: string;
68
+ }
69
+
70
+ async function buildContext(): Promise<CliContext> {
71
+ const creds = loadCredentialsJson(CREDENTIALS_PATH);
72
+ if (!creds) {
73
+ die('TotalReclaw is not set up. Run: openclaw totalreclaw onboard\n(or: tr pair)');
74
+ }
75
+
76
+ const mnemonic =
77
+ (typeof creds.mnemonic === 'string' && creds.mnemonic.trim()) ||
78
+ (typeof creds.recovery_phrase === 'string' && creds.recovery_phrase.trim()) ||
79
+ '';
80
+
81
+ if (!mnemonic) {
82
+ die('No recovery phrase in credentials.json. Run: openclaw totalreclaw onboard');
83
+ }
84
+
85
+ // Parse existing salt/userId from credentials.json
86
+ let existingSalt: Buffer | undefined;
87
+ let existingUserId: string | undefined;
88
+
89
+ const saltStr = typeof creds.salt === 'string' ? creds.salt : undefined;
90
+ if (saltStr) {
91
+ if (/^[0-9a-f]{64}$/i.test(saltStr)) {
92
+ existingSalt = Buffer.from(saltStr, 'hex');
93
+ } else {
94
+ existingSalt = Buffer.from(saltStr, 'base64');
95
+ }
96
+ }
97
+ existingUserId = typeof creds.userId === 'string' ? creds.userId : undefined;
98
+
99
+ const keys = deriveKeys(mnemonic, existingSalt);
100
+ const authKeyHex = keys.authKey.toString('hex');
101
+
102
+ const apiClient = createApiClient(SERVER_URL);
103
+
104
+ let userId: string;
105
+ if (existingUserId) {
106
+ userId = existingUserId;
107
+ } else {
108
+ // Register to get userId (idempotent on relay)
109
+ const authHash = computeAuthKeyHash(keys.authKey);
110
+ const saltHex = keys.salt.toString('hex');
111
+ try {
112
+ const result = await apiClient.register(authHash, saltHex);
113
+ userId = result.user_id;
114
+ } catch (err) {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ if (msg.includes('USER_EXISTS')) {
117
+ userId = authHash.slice(0, 32);
118
+ } else {
119
+ die(`Relay registration failed: ${msg}`);
120
+ }
121
+ }
122
+ }
123
+
124
+ return {
125
+ authKeyHex,
126
+ encryptionKey: keys.encryptionKey,
127
+ dedupKey: keys.dedupKey,
128
+ apiClient,
129
+ userId,
130
+ };
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Command: status
135
+ // ---------------------------------------------------------------------------
136
+
137
+ async function cmdStatus(): Promise<void> {
138
+ // Print onboarding + credentials state (never prints mnemonic — same as
139
+ // the `openclaw totalreclaw status` subcommand surface).
140
+ printStatus(CREDENTIALS_PATH, STATE_PATH, process.stdout);
141
+
142
+ // Additional: loaded.json check to confirm plugin hooks are active.
143
+ // Reads manifest written by register() in index.ts.
144
+ // Probe both install paths: extensions/ (local tgz installs) and npm/ (registry installs).
145
+ try {
146
+ const fs = await import('node:fs');
147
+ const candidatePaths = [
148
+ // extensions-path (local tgz / --force install) — .loaded.json sits at root, not dist/
149
+ path.join(os.homedir(), '.openclaw', 'extensions', 'totalreclaw', '.loaded.json'),
150
+ // npm-path (registry install) — .loaded.json inside dist/
151
+ path.join(os.homedir(), '.openclaw', 'npm', 'node_modules', '@totalreclaw', 'totalreclaw', 'dist', '.loaded.json'),
152
+ ];
153
+ const resolvedPath = candidatePaths.find((p) => fs.existsSync(p));
154
+ if (resolvedPath) {
155
+ const raw = fs.readFileSync(resolvedPath, 'utf-8');
156
+ const manifest = JSON.parse(raw) as {
157
+ version?: string;
158
+ bootCount?: number;
159
+ loadedAt?: number;
160
+ hybridMode?: boolean;
161
+ tools?: string[];
162
+ };
163
+ const ageMs = Date.now() - (manifest.loadedAt ?? 0);
164
+ const ageSec = Math.round(ageMs / 1000);
165
+ process.stdout.write(
166
+ `\n plugin: loaded (version=${manifest.version ?? '?'} bootCount=${manifest.bootCount ?? '?'} loaded=${ageSec}s ago)\n` +
167
+ ` hybrid-mode: ${manifest.hybridMode ? 'yes (use tr <cmd>)' : 'no'}\n` +
168
+ ` hooks: before_agent_start, agent_end, message_received, before_reset\n` +
169
+ ` note: tools from .loaded.json are STRIPPED by OC 2026.5.2 issue #223;\n` +
170
+ ` use \`tr <cmd>\` from shell instead\n`,
171
+ );
172
+ } else {
173
+ process.stdout.write('\n plugin: .loaded.json not found — plugin may not be loaded\n');
174
+ }
175
+ } catch {
176
+ // Best-effort
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Command: pair
182
+ // ---------------------------------------------------------------------------
183
+
184
+ async function cmdPair(args: string[]): Promise<void> {
185
+ // Delegate to the existing pair-cli-relay.ts via a thin wrapper.
186
+ // The pair flow is relay-brokered (works through Docker NAT).
187
+ // Phrase-safety: pair-cli-relay.ts is x25519-only; mnemonic never appears.
188
+ const outputMode = args.includes('--json') ? 'json' : args.includes('--url-pin') ? 'url-pin' : 'human';
189
+
190
+ const { runRelayPairCli } = await import('./pair-cli-relay.js');
191
+ const { defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
192
+
193
+ const io = buildDefaultPairCliIo();
194
+ const outcome = await runRelayPairCli('generate', {
195
+ relayBaseUrl: CONFIG.pairRelayUrl,
196
+ credentialsPath: CREDENTIALS_PATH,
197
+ onboardingStatePath: STATE_PATH,
198
+ logger: {
199
+ info: (m: string) => process.stderr.write(`[info] ${m}\n`),
200
+ warn: (m: string) => process.stderr.write(`[warn] ${m}\n`),
201
+ error: (m: string) => process.stderr.write(`[error] ${m}\n`),
202
+ },
203
+ pluginVersion: '3.3.8-rc.1',
204
+ deriveScopeAddress: undefined,
205
+ renderQr: defaultRenderQr,
206
+ io,
207
+ outputMode: outputMode as import('./pair-cli.js').PairCliOutputMode,
208
+ });
209
+
210
+ if (outcome.status !== 'completed' && outcome.status !== 'canceled') {
211
+ die(`Pairing ${outcome.status}`, 1);
212
+ }
213
+ if (outcome.status === 'canceled') {
214
+ process.exit(130);
215
+ }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Command: remember
220
+ // ---------------------------------------------------------------------------
221
+
222
+ async function cmdRemember(args: string[]): Promise<void> {
223
+ const text = args.join(' ').trim();
224
+ if (!text) {
225
+ die('Usage: tr remember <text>');
226
+ }
227
+
228
+ const ctx = await buildContext();
229
+
230
+ // Build a minimal MemoryTaxonomy v1 claim blob (same format as storeExtractedFacts)
231
+ const now = new Date().toISOString();
232
+ const factId = randomUUID().replace(/-/g, '');
233
+
234
+ // Encrypt the memory text
235
+ const blob = JSON.stringify({
236
+ text,
237
+ type: 'claim',
238
+ source: 'user',
239
+ scope: 'unspecified',
240
+ importance: 8,
241
+ metadata: {
242
+ type: 'claim',
243
+ source: 'user',
244
+ scope: 'unspecified',
245
+ importance: 8,
246
+ },
247
+ timestamp: now,
248
+ version: 'v1',
249
+ });
250
+ const encrypted_blob = encrypt(blob, ctx.encryptionKey);
251
+ const blind_indices = generateBlindIndices(text);
252
+ const content_fp = generateContentFingerprint(text, ctx.dedupKey);
253
+
254
+ const payload = {
255
+ id: factId,
256
+ timestamp: now,
257
+ encrypted_blob,
258
+ blind_indices,
259
+ decay_score: 8,
260
+ source: 'cli:tr-remember',
261
+ content_fp,
262
+ };
263
+
264
+ try {
265
+ await ctx.apiClient.store(ctx.userId, [payload], ctx.authKeyHex);
266
+ log(JSON.stringify({ ok: true, id: factId, text }));
267
+ } catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ die(`remember failed: ${msg}`);
270
+ }
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Command: recall
275
+ // ---------------------------------------------------------------------------
276
+
277
+ async function cmdRecall(args: string[]): Promise<void> {
278
+ const query = args.join(' ').trim();
279
+ if (!query) {
280
+ die('Usage: tr recall <query>');
281
+ }
282
+
283
+ const ctx = await buildContext();
284
+
285
+ // Generate word trapdoors for blind search
286
+ const trapdoors = generateBlindIndices(query);
287
+
288
+ if (trapdoors.length === 0) {
289
+ log(JSON.stringify({ ok: true, count: 0, memories: [] }));
290
+ return;
291
+ }
292
+
293
+ try {
294
+ const candidates = await ctx.apiClient.search(ctx.userId, trapdoors, 12, ctx.authKeyHex);
295
+
296
+ const memories: Array<{ id: string; text: string; score: number; timestamp: string }> = [];
297
+
298
+ for (const c of candidates) {
299
+ try {
300
+ const raw = decrypt(c.encrypted_blob, ctx.encryptionKey);
301
+ const parsed = JSON.parse(raw) as { text?: string };
302
+ if (parsed.text) {
303
+ memories.push({
304
+ id: c.fact_id,
305
+ text: parsed.text,
306
+ score: c.decay_score,
307
+ timestamp: new Date(c.timestamp).toISOString(),
308
+ });
309
+ }
310
+ } catch {
311
+ // Skip undecryptable
312
+ }
313
+ }
314
+
315
+ // Simple relevance sort by decay_score (descending)
316
+ memories.sort((a, b) => b.score - a.score);
317
+
318
+ log(JSON.stringify({ ok: true, count: memories.length, query, memories }));
319
+ } catch (err) {
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ die(`recall failed: ${msg}`);
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Dispatch
327
+ // ---------------------------------------------------------------------------
328
+
329
+ async function main(): Promise<void> {
330
+ const args = process.argv.slice(2);
331
+ const cmd = args[0];
332
+
333
+ switch (cmd) {
334
+ case 'status':
335
+ await cmdStatus();
336
+ break;
337
+
338
+ case 'pair':
339
+ await cmdPair(args.slice(1));
340
+ break;
341
+
342
+ case 'remember':
343
+ await cmdRemember(args.slice(1));
344
+ break;
345
+
346
+ case 'recall':
347
+ await cmdRecall(args.slice(1));
348
+ break;
349
+
350
+ case undefined:
351
+ case '--help':
352
+ case '-h':
353
+ process.stdout.write(
354
+ 'TotalReclaw hybrid CLI (OpenClaw 2026.5.2 issue #223 workaround)\n\n' +
355
+ 'Usage:\n' +
356
+ ' tr status — onboarding + plugin load state\n' +
357
+ ' tr pair [--json] — start a relay pairing session\n' +
358
+ ' tr remember <text> — store a memory\n' +
359
+ ' tr recall <query> — search memories (outputs JSON)\n\n' +
360
+ 'Environment:\n' +
361
+ ' TOTALRECLAW_SERVER_URL — relay URL (default: api-staging.totalreclaw.xyz)\n' +
362
+ ' TOTALRECLAW_CREDENTIALS_PATH — override credentials.json path\n',
363
+ );
364
+ break;
365
+
366
+ default:
367
+ die(`Unknown command: ${cmd}. Run \`tr --help\` for usage.`);
368
+ }
369
+ }
370
+
371
+ main().catch((err) => {
372
+ const msg = err instanceof Error ? err.message : String(err);
373
+ process.stderr.write(`tr: fatal: ${msg}\n`);
374
+ process.exit(2);
375
+ });