@totalreclaw/totalreclaw 3.3.7-rc.3 → 3.3.9-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,54 @@ 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.9-rc.1] — 2026-05-02
8
+
9
+ Pedro's QA on 3.3.8-rc.1 (Telegram → canonical OpenClaw 2026.5.2) revealed five issues that all stem from the same architectural problem — TotalReclaw's tools registered via `api.registerTool()` are blocked by OpenClaw 2026.5.2's tool-policy strip race (issue #223, filed upstream). Each release we ship adds a fix for one gate, hits another. 3.3.9-rc.1 pivots to hybrid-primary: the `tr` CLI is now the PRIMARY path. Native tools are kept for back-compat only.
10
+
11
+ ### Five issues from Pedro's 3.3.8-rc.1 QA — root causes and fixes:
12
+
13
+ 1. **Agent hallucinated "without an account, memories are local only"** — FALSE. TR is relay-based, no local storage. Fix: SKILL.md now has a "CRITICAL: Relay-based architecture" section at the top with an explicit forbidden-vocabulary denylist (`local`, `local-only`, `stored locally`, `on disk`, `without an account`, `local memory`, `local storage`). Both `skill/plugin/SKILL.md` and `skill/SKILL.md` updated.
14
+
15
+ 2. **Agent did not autonomously emit account-setup URL+PIN after install** — asked user instead. Fix: SKILL.md now makes step 4 (running `tr pair --json`) UNCONDITIONAL — no "Want me to set up an account?" gate. The URL+PIN is the consent moment.
16
+
17
+ 3. **Pair tool reply did NOT include the URL** ("open in your browser, enter PIN" with no link). Fix: `tr pair --json` output documented in SKILL.md with explicit JSON shape `{"url":"...","pin":"...","expires_at":"..."}`. Agent is instructed to parse and emit URL verbatim.
18
+
19
+ 4. **`totalreclaw_pair` not bound after install, `/totalreclaw-restart` did not restore binding** — root cause is issue #223 (tool-policy strip race). Fix: SKILL.md drops the "wait for tool-bind / /totalreclaw-restart / /new" cycle from the user-facing path. `tr status --json` replaces the tool-binding check. Agent no longer depends on `totalreclaw_*` binding.
20
+
21
+ 5. **Pair-page POST `/respond` returned 502 then 400** — separate relay bug, out of scope for this RC.
22
+
23
+ ### Changed — SKILL.md (both `skill/plugin/SKILL.md` and `skill/SKILL.md`):
24
+
25
+ - Added "CRITICAL: Relay-based architecture" section at top with positive assertion and forbidden-vocabulary denylist.
26
+ - Rewritten setup flow: Step 1 install → Step 2 verify CLI (`tr status --json`) → Step 3 credentials check → Step 4 emit URL+PIN via `tr pair --json` (unconditional) → Step 5 confirm.
27
+ - Dropped the entire "wait for tool-bind, restart with /totalreclaw-restart, fall back to /new" cycle.
28
+ - Added "tr CLI reference" section documenting `--json` flag shapes for all commands.
29
+ - Autonomous account setup: removed consent gate, agent runs `tr pair --json` immediately.
30
+ - User-visible line set updated: line 2 now confirms "hybrid mode"; line 3 emitted immediately from `tr pair --json` output.
31
+
32
+ ### Changed — `tr-cli.ts` → CLI JSON-first output:
33
+
34
+ - `tr status --json` → `{"version":"3.3.9-rc.1","onboarded":bool,"next_step":"pair|none","tool_count":N,"hybrid_mode":bool}`
35
+ - `tr pair --json` → `{"url":"...","pin":"...","expires_at":"..."}` (delegated to pair-cli-relay.ts, already JSON-capable)
36
+ - `tr remember --json '<fact>'` → `{"ok":true,"id":"...","claim_count":0}`
37
+ - `tr recall --json '<query>' --limit 5` → `{"results":[{"text":"...","score":0.8}]}`
38
+ - Plain text mode kept for direct user CLI use; `--json` flag required for agent shell calls.
39
+ - Updated plugin version reference to `3.3.9-rc.1` in pair delegation.
40
+
41
+ ### New tests:
42
+
43
+ - `skill-md-hybrid-primary.test.ts` — asserts SKILL.md contains hybrid-primary instructions, forbidden-vocabulary denylist, relay-based architecture assertion, and autonomous pair call.
44
+ - `tr-cli-json-output.test.ts` — asserts each `tr <cmd> --json` code path returns parseable JSON with expected keys (via static source analysis).
45
+
46
+ ### Not changed:
47
+
48
+ - Hooks (`before_agent_start`, `agent_end`, `message_received`, `before_reset`) — not affected by issue #223, unchanged.
49
+ - HTTP routes (4 QR-pairing routes) — unchanged.
50
+ - `/totalreclaw-restart` slash command — kept for environments where users want a manual reload.
51
+ - `api.registerTool()` calls — kept for back-compat (older OpenClaw versions where tools work).
52
+ - Relay code — 502/400 pair page bugs are out of scope.
53
+ - Hermes Python code — separate runtime.
54
+
7
55
  ## [3.3.7-rc.3] — 2026-05-04
8
56
 
9
57
  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.
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
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.3
3
+ description: "Install + set up TotalReclaw encrypted memory for OpenClaw, then use tr CLI for remember / recall. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', or any remember / recall request."
4
+ version: 3.3.9-rc.1
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
@@ -26,9 +26,33 @@ metadata:
26
26
 
27
27
  > The canonical install flow lives at <https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/openclaw-setup.md>. If a user pastes that URL in chat, fetch it and follow its agent-instructions section. This SKILL.md mirrors the same steps so you can act even if the URL is unreachable.
28
28
 
29
+ ## CRITICAL: Relay-based architecture (read before anything else)
30
+
31
+ **TotalReclaw is RELAY-BASED. All memories require a paired account on the staging or production relay (depending on RC vs stable). There is NO local-only mode. NEVER claim memories are stored locally without an account.**
32
+
33
+ Forbidden vocabulary — these claims are FALSE and MUST NEVER appear in your responses:
34
+ - "local"
35
+ - "local-only"
36
+ - "stored locally"
37
+ - "on disk"
38
+ - "without an account"
39
+ - "local memory"
40
+ - "local storage"
41
+ - any phrasing suggesting memories can be stored without pairing a relay account
42
+
43
+ If you find yourself about to say any of these, STOP. The correct statement is: "TotalReclaw requires a paired relay account to store memories — run `tr pair` to set one up."
44
+
29
45
  ## Top-level rule (read this before anything else)
30
46
 
31
- **Emit ONLY the user-visible lines listed below. Do not narrate tool calls. Do not describe what you're doing or about to do. Errors are the only allowed deviation, and only as a single tight line.** The expected user-facing transcript for a healthy install is **5–6 short lines TOTAL** (lines 1, 2, 5, 6 always; lines 3 and 4 only if the corresponding condition fires). Anything more is a SKILL violation. Telegram and similar transports edit the bot's messages live as your tool calls progress — every interim "Let me check…" / "Plugin loaded. Let me verify…" line shows up to the user as awkward edit churn, even if you "delete" it. The fix is to never emit it in the first place.
47
+ **Emit ONLY the user-visible lines listed below. Do not narrate tool calls. Do not describe what you're doing or about to do. Errors are the only allowed deviation, and only as a single tight line.** The expected user-facing transcript for a healthy install is **4–5 short lines TOTAL**. Anything more is a SKILL violation. Telegram and similar transports edit the bot's messages live as your tool calls progress — every interim "Let me check…" / "Plugin loaded. Let me verify…" line shows up to the user as awkward edit churn, even if you "delete" it. The fix is to never emit it in the first place.
48
+
49
+ ## Hybrid-primary architecture (3.3.9-rc.1)
50
+
51
+ OpenClaw 2026.5.2 has a tool-policy-pipeline bug (issue #223) that strips non-bundled plugin tools before they reach the agent toolset. **3.3.9-rc.1 makes the `tr` CLI the PRIMARY path for all memory operations.** Native tools (`totalreclaw_*`) are kept for back-compat with older OpenClaw versions where they work, but the agent MUST NOT depend on them. Use `tr` from your shell tool for everything.
52
+
53
+ **The `tr` binary is installed at:** `~/.openclaw/extensions/totalreclaw/dist/tr-cli.js`
54
+ **Always invoke via:** `node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js <cmd> [--json]`
55
+ **Never rely on `totalreclaw_*` tool binding** — it may not be available due to issue #223.
32
56
 
33
57
  ## User-visible line set (THIS IS ALL YOU SAY)
34
58
 
@@ -36,70 +60,70 @@ Speak only these lines, in order. Substitute the bracketed placeholders with the
36
60
 
37
61
  1. (start of install)
38
62
  > Installing TotalReclaw…
39
- 2. (after install + auto-reload + `totalreclaw_pair` is bound)
40
- > ✓ Installed (totalreclaw &lt;version&gt;)
41
- 3. (optional only if auto-reload didn't fire and you need to issue `/totalreclaw-restart`)
42
- > Restarting OpenClaw to load the plugin…
43
- 4. (optional — only if you successfully disabled the built-in memory tool to prevent dual-write)
44
- > Disabled built-in memory tool.
45
- 5. (account-setup URL + PIN, immediately after `totalreclaw_pair` returns)
63
+ 2. (after install + CLI verify `tr status` works)
64
+ > ✓ Installed (totalreclaw &lt;version&gt;, hybrid mode)
65
+ 3. (account-setup URL + PIN, IMMEDIATELY after `tr pair --json` returns no consent gate)
46
66
  > Open &lt;url&gt; in your browser. Enter PIN &lt;pin&gt;. Generate or paste a 12-word recovery phrase. Reply `done` once it's sealed.
47
- 6. (done + free-tier confirmation)
67
+ 4. (done + free-tier confirmation)
48
68
  > ✓ You're set up on the TotalReclaw free tier — 500 memories/month, unlimited reads. https://totalreclaw.xyz/pricing. Ask me to remember anything.
49
69
 
50
70
  If existing credentials are detected (step "Check for existing credentials" below), emit ONLY this line and stop:
51
71
  > ✓ TotalReclaw is already set up.
52
72
 
53
- ## Setup flow
54
-
55
- If `totalreclaw_*` tools are already bound to your toolset, SKIP install and jump to step 4. Otherwise run all steps in order.
73
+ ## Setup flow (hybrid-primary)
56
74
 
57
75
  1. **Install plugin first, skill second (silent — emit user-visible line 1 once at the start, then no prose until line 2).** Run via your shell tool, in this exact order:
58
76
  ```
59
77
  openclaw plugins install @totalreclaw/totalreclaw
60
78
  openclaw skills install totalreclaw
61
79
  ```
62
- For RC: replace the first command with `openclaw plugins install @totalreclaw/totalreclaw@rc`. **Plugin BEFORE skill is deliberate** (3.3.4-rc.1 — issue from Pedro's QA on rc.3): the skill install triggers a config-driven SIGUSR1 gateway reload that can race a concurrent plugin install. Plugin-first lets the plugin land cleanly, then the skill's reload picks it up on the next gateway loop. If you accidentally do skill-first and the plugin install dies mid-flight, just retry `openclaw plugins install @totalreclaw/totalreclaw` once — it's idempotent.
80
+ For RC: replace the first command with `openclaw plugins install @totalreclaw/totalreclaw@rc`. **Plugin BEFORE skill is deliberate** (3.3.4-rc.1 — issue from Pedro's QA on rc.3): the skill install triggers a config-driven SIGUSR1 gateway reload that can race a concurrent plugin install.
63
81
 
64
82
  If either shell call fails with `command not found` / ENOENT / "not authorized" (managed-service or shell-restricted environment), tell the user verbatim *"Your environment doesn't expose the OpenClaw CLI to me. Please install `totalreclaw` from your service's plugins / skills UI, then reply `done`."* Wait for `done` and skip to step 3.
65
83
 
66
- 2. **Wait for auto-reloadthen issue `/totalreclaw-restart` autonomously if needed (silent — emit user-visible line 2 on success, line 3 only if you had to restart).** OpenClaw's default config (`gateway.reload.mode = "hybrid"`) auto-restarts the gateway via a file-watcher within 1-3 seconds. Wait ~5s, then run `openclaw plugins list | grep totalreclaw` and confirm `totalreclaw_pair` is in your toolset. When bound, emit user-visible line 2 and proceed.
67
-
68
- **If `totalreclaw_pair` is NOT visible after 10s — restart imperative (read carefully):**
69
-
70
- > Your IMMEDIATE next message must be the literal slash command `/totalreclaw-restart` nothing else. Do not narrate. Do not ask permission. Do not describe what `/totalreclaw-restart` does. Do not propose alternatives. Just send `/totalreclaw-restart`. You may emit user-visible line 3 (`Restarting OpenClaw to load the plugin…`) ONCE in the same turn, then issue `/totalreclaw-restart`. Nothing else.
84
+ 2. **Verify CLI works — emit user-visible line 2 when successful.** Run:
85
+ ```
86
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status --json
87
+ ```
88
+ Parse the JSON. When you get `{"version": "...", "hybrid_mode": true, ...}`, emit user-visible line 2 with the version substituted. **Do NOT wait for tool binding. Do NOT issue `/totalreclaw-restart`. Do NOT check whether `totalreclaw_pair` is in your toolset.** The CLI works independently of tool binding. If `tr status` exits non-zero or the file doesn't exist, wait 5s and retry once — it may still be installing.
71
89
 
72
- `/totalreclaw-restart` is in-process it drains active runs cleanly; your shell is NOT shelling out to `docker restart` or `openclaw gateway restart` (those would suicide the host). After `/totalreclaw-restart`, wait ~3s and re-run `openclaw plugins list`. (3.3.7-rc.2 renamed this from `/restart` because OpenClaw reserves `/restart` for its built-in the plugin-side fallback can only attach to a unique name. The 5-tier auth resolver is unchanged; only the command name moved.)
90
+ 3. **Check for existing credentials (silent unless found).** Run `test -s ~/.totalreclaw/credentials.json`. If it exists and is non-empty, emit *"✓ TotalReclaw is already set up."* and stop.
73
91
 
74
- **If `/totalreclaw-restart` returns "You are not authorized to use this command":** surface this verbatim and stop. Do NOT loop `/totalreclaw-restart` calls. Do NOT propose alternatives.
92
+ 4. **Set up the user's TotalReclaw account IMMEDIATELY no consent gate.** As soon as `tr status --json` returns and credentials are not present, run:
93
+ ```
94
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js pair --json
95
+ ```
96
+ This is UNCONDITIONAL. Do NOT ask "Would you like to set up an account?" or "Want me to pair now?". The user installed TotalReclaw — setup is the expected next step. The URL + PIN itself is the consent moment (the user can simply not open the page to opt out). Parse the JSON response `{"url": "...", "pin": "...", "expires_at": "..."}` and emit user-visible line 3. If `tr pair --json` is not available (old install), fall back to `totalreclaw_pair` tool or `openclaw totalreclaw pair generate --url-pin-only`.
75
97
 
76
- > Your Telegram channel needs `allowFrom` configured. Run on your host: `jq '.channels.<channel>.allowFrom = ["<your-id>"]' ~/.openclaw/openclaw.json | sponge ~/.openclaw/openclaw.json && docker restart <your-container-name>`. Substitute `<channel>` (e.g. `telegram`, `slack`), `<your-id>` (Telegram: `@username` or numeric `chat_id`; Slack: workspace member ID), and `<your-container-name>` (`docker ps` shows it). For non-Docker installs substitute `openclaw gateway restart` for the `docker restart` portion. On a managed service, ask the service's support to enable slash commands. Reply `done` after.
98
+ 5. **Verify and confirm (silent until line 4).** After the user says `done`, re-run `test -s ~/.totalreclaw/credentials.json`. If missing, the PIN expired run `tr pair --json` again and resend line 3 once. If present, emit user-visible line 4 and stop.
77
99
 
78
- Then wait for `done`. Do NOT retry the install. Do NOT keep restarting. Do NOT issue `/new` it wipes the chat context, the agent forgets it was mid-install, and the user's next message is treated as a fresh install request which retries from scratch and re-trips the scanner block (Pedro QA, 3.3.5-rc.1).
100
+ ## `tr` CLI reference (hybrid-primary commands)
79
101
 
80
- **Forbidden vocabulary** (these are anti-patterns they show up to the user even if your transport edits them later, and they violate the silence rule):
81
- - "Let me check that…"
82
- - "Plugin loaded. Let me verify…"
83
- - "I'll now…"
84
- - "Quick fix…"
85
- - "However, I don't see…"
86
- - "I need to use…"
87
- - "Should I /totalreclaw-restart" / "Do you have a public URL" / "Want me to restart" / "Let me check if the tool is bound" / "I need permission to restart"
88
- - "Now installing…" / "Got it" / "In parallel…" / "Step 1a/1b/1c"
102
+ Always use `--json` flag when running from shell for machine-parseable output. Plain text mode is for direct user invocation only.
89
103
 
90
- Substitute all of these with silence — the next user-visible line in the set, or nothing.
104
+ ```bash
105
+ # Status check (parse JSON to confirm install + onboarding state)
106
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status --json
107
+ # Returns: {"version":"3.3.9-rc.1","onboarded":false,"next_step":"pair","tool_count":17,"hybrid_mode":true}
91
108
 
92
- 3. **Verify `totalreclaw_pair` is bound.** If still missing after `/totalreclaw-restart` (or after the user fixed allowFrom and said `done`), tell them the restart didn't take effect and ask them to retry. Do not loop the install.
109
+ # Pair / account setup
110
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js pair --json
111
+ # Returns: {"url":"https://...","pin":"123456","expires_at":"2026-05-05T..."}
93
112
 
94
- 4. **Check for existing credentials (silent unless found).** Run `test -s ~/.totalreclaw/credentials.json`. If it exists and is non-empty, emit *"✓ TotalReclaw is already set up."* and stop.
113
+ # Remember a fact
114
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js remember --json "I work at Graph Foundation"
115
+ # Returns: {"ok":true,"id":"...","claim_count":N}
95
116
 
96
- 5. **Set up the user's TotalReclaw account (emit user-visible line 5 once, after `totalreclaw_pair` returns).** Call `totalreclaw_pair` silently (the account-setup tool — name kept for backward compatibility). It returns `{url, pin, qr_ascii, qr_png_b64, qr_unicode, expires_at_ms}` (see "Rendering the QR" below). Then emit user-visible line 5 with `<url>` and `<pin>` substituted. Do not pre-narrate ("I'll walk you through…", "Setting up your account now…") — line 5 itself is the only thing the user needs to see. The URL + PIN itself is the real consent moment: surfacing them verbatim is what gives the user the chance to opt out by simply not opening the page.
117
+ # Recall memories
118
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js recall --json "where does the user work" --limit 5
119
+ # Returns: {"results":[{"text":"...","score":0.8},...]}
120
+ ```
97
121
 
98
- 6. **Verify and confirm (silent until line 6).** After the user says `done`, re-run `test -s ~/.totalreclaw/credentials.json`. If missing, the PIN expired call `totalreclaw_pair` again silently and resend line 5 once. If present, emit user-visible line 6 and stop.
122
+ **Note on tool names**: `totalreclaw_remember`, `totalreclaw_recall`, etc. may or may not appear in your toolset depending on OpenClaw version. Under hybrid-primary, you use the `tr` CLI instead. If native tools ARE available (older OpenClaw or future OC fix), they work too but always prefer `tr` for reliability.
99
123
 
100
124
  ## Rendering the QR on your transport (rc.5+)
101
125
 
102
- When you receive the `totalreclaw_pair` payload, render the QR based on your chat transport:
126
+ When you receive the `totalreclaw_pair` payload (from tool OR `tr pair --json`), render the QR based on your chat transport:
103
127
 
104
128
  - **Transport supports image attachments** (Telegram, Slack, web chat, any transport with file/image upload): decode `qr_png_b64` from base64 to bytes, save to a tempfile with a `.png` suffix, and attach the tempfile as an image. Also show the URL + PIN as text so users who prefer clicking can still do so.
105
129
  - **Terminal-only transport** (OpenClaw native CLI chat, bare SSH shell): emit `qr_unicode` inline in your reply. It renders as block characters in most terminal fonts; the user scans from their phone.
@@ -109,24 +133,64 @@ The QR encodes ONLY the account-setup URL. The 6-digit PIN is a separate out-of-
109
133
 
110
134
  ## Phrase safety (HARD — never break)
111
135
 
112
- NEVER echo / generate / ask the user to paste a recovery phrase in chat. NEVER invoke `openclaw totalreclaw onboard` (bare interactive form), `totalreclaw setup`, or any phrase-touching CLI via your shell tool (stdout enters LLM context). The ONLY agent-facilitated account-setup paths are (in order of preference): (1) the `totalreclaw_pair` account-setup tool; (2) if the tool is missing from your tool list a known gap for some container-based agents, issue #87 — shell out to EITHER `openclaw totalreclaw pair generate --url-pin-only` (single-line JSON `{v,url,pin,expires_at_ms}`) OR `openclaw totalreclaw onboard --pair-only` (single-line JSON `{v,pair_url,pin,expires_at_ms}`, issue #95 — same account-setup flow, `pair_url` key naming). Both have zero phrase material on stdout (account-setup crypto is x25519-only, not BIP-39). If the user pastes a phrase anyway: tell them it is compromised, call `totalreclaw_pair` with `mode=generate` for a fresh wallet.
136
+ NEVER echo / generate / ask the user to paste a recovery phrase in chat. NEVER invoke `openclaw totalreclaw onboard` (bare interactive form), `totalreclaw setup`, or any phrase-touching CLI via your shell tool (stdout enters LLM context). The ONLY agent-facilitated account-setup paths are (in order of preference): (1) `tr pair --json`; (2) the `totalreclaw_pair` account-setup tool; (3) if both are missing — shell out to EITHER `openclaw totalreclaw pair generate --url-pin-only` (single-line JSON `{v,url,pin,expires_at_ms}`) OR `openclaw totalreclaw onboard --pair-only` (single-line JSON `{v,pair_url,pin,expires_at_ms}`). Both have zero phrase material on stdout (account-setup crypto is x25519-only, not BIP-39). If the user pastes a phrase anyway: tell them it is compromised, re-run `tr pair --json` for a fresh wallet.
113
137
 
114
138
  ## Usage (post-setup)
115
139
 
116
- - Stable user facts / preferences / identity / "remember X" → `totalreclaw_remember` (don't ask permission).
117
- - First-person factual query ("do I / what's my / where do I …") → `totalreclaw_recall` FIRST, then answer from returned facts. If 0 results, say so.
118
- - "forget / pin / unpin / retype / move to <scope>" X → `totalreclaw_recall` first for `factId`, then `totalreclaw_forget` / `_pin` / `_unpin` / `_retype` / `_set_scope`.
119
- - "status" → `totalreclaw_status`. "upgrade" → `totalreclaw_upgrade` (paste Stripe URL verbatim). Post-upgrade testnet → mainnet: `totalreclaw_migrate` with `dry_run` first.
120
- - "import / export / consolidate" `totalreclaw_import_from` (`dry_run=true` first; `_import_batch` for >50 chunks) · `totalreclaw_export` · `totalreclaw_consolidate` (`dry_run` first).
140
+ - Stable user facts / preferences / identity / "remember X":
141
+ ```
142
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js remember --json "<fact>"
143
+ ```
144
+ - First-person factual query ("do I / what's my / where do I…"):
145
+ ```
146
+ node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js recall --json "<query>" --limit 5
147
+ ```
148
+ Recall FIRST, then answer from returned facts. If 0 results, say so.
149
+ - For forget / pin / unpin / retype / set_scope — use native tools if available, or ask user to run `openclaw totalreclaw` subcommand.
150
+ - "status" → `tr status --json` (parse version + onboarded + next_step).
151
+ - "upgrade" → `totalreclaw_upgrade` native tool (paste Stripe URL verbatim). Post-upgrade testnet → mainnet: `totalreclaw_migrate` with `dry_run` first.
152
+ - "import / export / consolidate" → `totalreclaw_import_from` · `totalreclaw_export` · `totalreclaw_consolidate` native tools.
153
+
154
+ **Forbidden vocabulary** (these are anti-patterns that violate the silence rule):
155
+ - "Let me check that…"
156
+ - "Plugin loaded. Let me verify…"
157
+ - "I'll now…"
158
+ - "Quick fix…"
159
+ - "However, I don't see…"
160
+ - "I need to use…"
161
+ - "Should I /totalreclaw-restart" / "Do you have a public URL" / "Want me to restart" / "Let me check if the tool is bound" / "I need permission to restart"
162
+ - "Now installing…" / "Got it" / "In parallel…" / "Step 1a/1b/1c"
163
+ - "local" / "local-only" / "stored locally" / "on disk" / "without an account" / "local memory" / "local storage"
121
164
 
122
165
  ## Diagnostics
123
166
 
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).
125
- - User says done but `credentials.json` missing PIN expired or entered wrong phrase; call `totalreclaw_pair` again.
167
+ - `tr` CLI not found → plugin not installed or install still in progress. Wait 5s, retry. If still missing, retry `openclaw plugins install @totalreclaw/totalreclaw@rc` once.
168
+ - `totalreclaw_*` tools not visible → EXPECTED under OpenClaw 2026.5.2 (issue #223). Use `tr` CLI instead. Do not restart, do not issue `/totalreclaw-restart`, do not issue `/new`.
169
+ - If `/totalreclaw-restart` is needed for another reason, issue the literal slash command `/totalreclaw-restart` — nothing else. Do not narrate.
170
+ - User says done but `credentials.json` missing → PIN expired or entered wrong phrase; re-run `tr pair --json` and resend line 3 once.
126
171
  - `onboarding required` → credentials missing; redo from the account-setup step.
127
- - `quota exceeded` → `totalreclaw_status`, then offer `totalreclaw_upgrade`.
172
+ - `quota exceeded` → `tr status --json` or `totalreclaw_status`, then offer `totalreclaw_upgrade`.
128
173
  - `No LLM available for auto-extraction` at startup → provider key unreachable; check `~/.openclaw/agents/<agent>/agent/auth-profiles.json` or plugin config `extraction.llm`.
129
174
 
175
+ ## Required post-install config (OpenClaw 2026.5.2+)
176
+
177
+ Add to `~/.openclaw/openclaw.json` after installing:
178
+ ```json
179
+ {
180
+ "plugins": {
181
+ "allow": ["totalreclaw"],
182
+ "entries": {
183
+ "totalreclaw": {
184
+ "hooks": { "allowConversationAccess": true }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ ```
190
+
191
+ `plugins.allow` suppresses the "plugins.allow is empty" warning. `hooks.allowConversationAccess` unlocks typed hooks (`agent_end`, etc.) for auto-extraction.
192
+
130
193
  ## Tool surface
131
194
 
132
- `totalreclaw_pair` (ONLY account-setup path) · `_remember` · `_recall` · `_forget` · `_pin` · `_unpin` · `_retype` · `_set_scope` · `_export` · `_status` · `_upgrade` · `_migrate` · `_import_from` · `_import_batch` · `_consolidate` · `_onboarding_start` (pointer to local-terminal wizard, for users explicitly rejecting the browser flow) · `_report_qa_bug` (RC only).
195
+ Hybrid-primary: `tr remember` · `tr recall` · `tr pair` · `tr status` (primary path for all agent ops)
196
+ Native fallback (when available): `totalreclaw_pair` · `_remember` · `_recall` · `_forget` · `_pin` · `_unpin` · `_retype` · `_set_scope` · `_export` · `_status` · `_upgrade` · `_migrate` · `_import_from` · `_import_batch` · `_consolidate` · `_onboarding_start` · `_report_qa_bug` (RC only)
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,378 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tr — TotalReclaw hybrid CLI (3.3.9-rc.1 primary architecture)
4
+ *
5
+ * OpenClaw 2026.5.2 has a tool-policy-pipeline bug (issue #223) that strips non-bundled plugin
6
+ * tools before they reach the agent toolset. In 3.3.9-rc.1, this CLI is the PRIMARY path for
7
+ * all agent memory operations (not a fallback). The agent runs `tr <cmd> --json` from shell;
8
+ * hooks (before_agent_start, agent_end, message_received, before_reset) continue via the
9
+ * unbroken hook code path.
10
+ *
11
+ * Phrase-safety: this CLI reads credentials.json (mnemonic at rest) but NEVER
12
+ * prints the mnemonic to stdout, stderr, or any log. Phrase only enters via QR-pair
13
+ * browser tier (pair-cli.ts / pair-cli-relay.ts — unchanged).
14
+ *
15
+ * Commands:
16
+ * tr status [--json] — print onboarding + credentials state
17
+ * tr pair [--json] — start a relay pairing session, print URL+PIN+QR
18
+ * tr remember [--json] <text> — store a memory in the encrypted vault
19
+ * tr recall [--json] [--limit N] <query> — search the encrypted vault
20
+ *
21
+ * --json flag: all agent-facing CLI calls MUST use --json for clean machine-parseable output.
22
+ * Plain text mode is for direct user CLI use only.
23
+ *
24
+ * Install: wired via package.json `bin.tr` → dist/tr-cli.js
25
+ * Usage from container: `docker exec tr-openclaw node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status --json`
26
+ */
27
+ import path from 'node:path';
28
+ import os from 'node:os';
29
+ import { randomUUID } from 'node:crypto';
30
+ import { CONFIG } from './config.js';
31
+ import { loadCredentialsJson } from './fs-helpers.js';
32
+ import { printStatus } from './onboarding-cli.js';
33
+ import { deriveKeys, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
34
+ import { createApiClient } from './api-client.js';
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+ const CREDENTIALS_PATH = CONFIG.credentialsPath;
39
+ const SERVER_URL = CONFIG.serverUrl;
40
+ const STATE_PATH = CONFIG.onboardingStatePath;
41
+ const PLUGIN_VERSION = '3.3.9-rc.1';
42
+ function die(msg, code = 1) {
43
+ process.stderr.write(`tr: ${msg}\n`);
44
+ process.exit(code);
45
+ }
46
+ function log(msg) {
47
+ process.stdout.write(msg + '\n');
48
+ }
49
+ /** Parse --flag from args array, returning the cleaned args without the flag. */
50
+ function popFlag(args, flag) {
51
+ const idx = args.indexOf(flag);
52
+ if (idx === -1)
53
+ return [false, args];
54
+ return [true, [...args.slice(0, idx), ...args.slice(idx + 1)]];
55
+ }
56
+ /** Parse --limit N from args, returning [limit, cleanedArgs]. Default: defaultLimit. */
57
+ function popLimitFlag(args, defaultLimit) {
58
+ const idx = args.indexOf('--limit');
59
+ if (idx === -1 || idx + 1 >= args.length)
60
+ return [defaultLimit, args];
61
+ const n = parseInt(args[idx + 1], 10);
62
+ const limit = isNaN(n) || n < 1 ? defaultLimit : n;
63
+ return [limit, [...args.slice(0, idx), ...args.slice(idx + 2)]];
64
+ }
65
+ async function buildContext() {
66
+ const creds = loadCredentialsJson(CREDENTIALS_PATH);
67
+ if (!creds) {
68
+ die('TotalReclaw is not set up. Run: node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js pair --json');
69
+ }
70
+ const mnemonic = (typeof creds.mnemonic === 'string' && creds.mnemonic.trim()) ||
71
+ (typeof creds.recovery_phrase === 'string' && creds.recovery_phrase.trim()) ||
72
+ '';
73
+ if (!mnemonic) {
74
+ die('No recovery phrase in credentials.json. Run: tr pair --json');
75
+ }
76
+ // Parse existing salt/userId from credentials.json
77
+ let existingSalt;
78
+ let existingUserId;
79
+ const saltStr = typeof creds.salt === 'string' ? creds.salt : undefined;
80
+ if (saltStr) {
81
+ if (/^[0-9a-f]{64}$/i.test(saltStr)) {
82
+ existingSalt = Buffer.from(saltStr, 'hex');
83
+ }
84
+ else {
85
+ existingSalt = Buffer.from(saltStr, 'base64');
86
+ }
87
+ }
88
+ existingUserId = typeof creds.userId === 'string' ? creds.userId : undefined;
89
+ const keys = deriveKeys(mnemonic, existingSalt);
90
+ const authKeyHex = keys.authKey.toString('hex');
91
+ const apiClient = createApiClient(SERVER_URL);
92
+ let userId;
93
+ if (existingUserId) {
94
+ userId = existingUserId;
95
+ }
96
+ else {
97
+ // Register to get userId (idempotent on relay)
98
+ const authHash = computeAuthKeyHash(keys.authKey);
99
+ const saltHex = keys.salt.toString('hex');
100
+ try {
101
+ const result = await apiClient.register(authHash, saltHex);
102
+ userId = result.user_id;
103
+ }
104
+ catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ if (msg.includes('USER_EXISTS')) {
107
+ userId = authHash.slice(0, 32);
108
+ }
109
+ else {
110
+ die(`Relay registration failed: ${msg}`);
111
+ }
112
+ }
113
+ }
114
+ return {
115
+ authKeyHex,
116
+ encryptionKey: keys.encryptionKey,
117
+ dedupKey: keys.dedupKey,
118
+ apiClient,
119
+ userId,
120
+ };
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // Command: status
124
+ // ---------------------------------------------------------------------------
125
+ async function cmdStatus(jsonMode) {
126
+ // Probe plugin manifest for version/hybridMode/toolCount.
127
+ let pluginVersion;
128
+ let bootCount;
129
+ let hybridMode = true; // default true in 3.3.9-rc.1 (hybrid-primary)
130
+ let toolCount;
131
+ let loadedAgeSec;
132
+ try {
133
+ const fs = await import('node:fs');
134
+ const candidatePaths = [
135
+ path.join(os.homedir(), '.openclaw', 'extensions', 'totalreclaw', '.loaded.json'),
136
+ path.join(os.homedir(), '.openclaw', 'npm', 'node_modules', '@totalreclaw', 'totalreclaw', 'dist', '.loaded.json'),
137
+ ];
138
+ const resolvedPath = candidatePaths.find((p) => fs.existsSync(p));
139
+ if (resolvedPath) {
140
+ const raw = fs.readFileSync(resolvedPath, 'utf-8');
141
+ const manifest = JSON.parse(raw);
142
+ pluginVersion = manifest.version ?? PLUGIN_VERSION;
143
+ bootCount = manifest.bootCount;
144
+ hybridMode = manifest.hybridMode !== false; // default true
145
+ toolCount = manifest.tools?.length;
146
+ const ageMs = Date.now() - (manifest.loadedAt ?? 0);
147
+ loadedAgeSec = Math.round(ageMs / 1000);
148
+ }
149
+ }
150
+ catch {
151
+ // Best-effort
152
+ }
153
+ // Check onboarding state
154
+ const creds = loadCredentialsJson(CREDENTIALS_PATH);
155
+ const onboarded = !!creds;
156
+ if (jsonMode) {
157
+ // JSON-first output for agent parsing
158
+ const out = {
159
+ version: pluginVersion ?? PLUGIN_VERSION,
160
+ onboarded,
161
+ next_step: onboarded ? 'none' : 'pair',
162
+ tool_count: toolCount ?? 17,
163
+ hybrid_mode: hybridMode,
164
+ };
165
+ if (bootCount !== undefined)
166
+ out.boot_count = bootCount;
167
+ if (loadedAgeSec !== undefined)
168
+ out.loaded_age_sec = loadedAgeSec;
169
+ log(JSON.stringify(out));
170
+ }
171
+ else {
172
+ // Human-readable plain text for direct user CLI use
173
+ printStatus(CREDENTIALS_PATH, STATE_PATH, process.stdout);
174
+ process.stdout.write(`\n plugin: ${pluginVersion ? `loaded (version=${pluginVersion}` + (bootCount !== undefined ? ` bootCount=${bootCount}` : '') + (loadedAgeSec !== undefined ? ` loaded=${loadedAgeSec}s ago` : '') + ')' : 'not found in .loaded.json'}\n` +
175
+ ` hybrid-mode: ${hybridMode ? 'yes (primary — use tr <cmd> --json)' : 'no'}\n` +
176
+ ` hooks: before_agent_start, agent_end, message_received, before_reset\n`);
177
+ }
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Command: pair
181
+ // ---------------------------------------------------------------------------
182
+ async function cmdPair(args) {
183
+ // Delegate to the existing pair-cli-relay.ts via a thin wrapper.
184
+ // The pair flow is relay-brokered (works through Docker NAT).
185
+ // Phrase-safety: pair-cli-relay.ts is x25519-only; mnemonic never appears.
186
+ const outputMode = args.includes('--json') ? 'json' : args.includes('--url-pin') ? 'url-pin' : 'human';
187
+ const { runRelayPairCli } = await import('./pair-cli-relay.js');
188
+ const { defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
189
+ const io = buildDefaultPairCliIo();
190
+ const outcome = await runRelayPairCli('generate', {
191
+ relayBaseUrl: CONFIG.pairRelayUrl,
192
+ credentialsPath: CREDENTIALS_PATH,
193
+ onboardingStatePath: STATE_PATH,
194
+ logger: {
195
+ info: (m) => process.stderr.write(`[info] ${m}\n`),
196
+ warn: (m) => process.stderr.write(`[warn] ${m}\n`),
197
+ error: (m) => process.stderr.write(`[error] ${m}\n`),
198
+ },
199
+ pluginVersion: PLUGIN_VERSION,
200
+ deriveScopeAddress: undefined,
201
+ renderQr: defaultRenderQr,
202
+ io,
203
+ outputMode: outputMode,
204
+ });
205
+ if (outcome.status !== 'completed' && outcome.status !== 'canceled') {
206
+ die(`Pairing ${outcome.status}`, 1);
207
+ }
208
+ if (outcome.status === 'canceled') {
209
+ process.exit(130);
210
+ }
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Command: remember
214
+ // ---------------------------------------------------------------------------
215
+ async function cmdRemember(rawArgs) {
216
+ const [jsonMode, args] = popFlag(rawArgs, '--json');
217
+ const text = args.join(' ').trim();
218
+ if (!text) {
219
+ die('Usage: tr remember [--json] <text>');
220
+ }
221
+ const ctx = await buildContext();
222
+ // Build a minimal MemoryTaxonomy v1 claim blob (same format as storeExtractedFacts)
223
+ const now = new Date().toISOString();
224
+ const factId = randomUUID().replace(/-/g, '');
225
+ // Encrypt the memory text
226
+ const blob = JSON.stringify({
227
+ text,
228
+ type: 'claim',
229
+ source: 'user',
230
+ scope: 'unspecified',
231
+ importance: 8,
232
+ metadata: {
233
+ type: 'claim',
234
+ source: 'user',
235
+ scope: 'unspecified',
236
+ importance: 8,
237
+ },
238
+ timestamp: now,
239
+ version: 'v1',
240
+ });
241
+ const encrypted_blob = encrypt(blob, ctx.encryptionKey);
242
+ const blind_indices = generateBlindIndices(text);
243
+ const content_fp = generateContentFingerprint(text, ctx.dedupKey);
244
+ const payload = {
245
+ id: factId,
246
+ timestamp: now,
247
+ encrypted_blob,
248
+ blind_indices,
249
+ decay_score: 8,
250
+ source: 'cli:tr-remember',
251
+ content_fp,
252
+ };
253
+ try {
254
+ await ctx.apiClient.store(ctx.userId, [payload], ctx.authKeyHex);
255
+ if (jsonMode) {
256
+ // JSON-first output for agent parsing
257
+ // claim_count requires an extra relay call to tally stored claims; not worth the latency — use 0
258
+ log(JSON.stringify({ ok: true, id: factId, claim_count: 0 }));
259
+ }
260
+ else {
261
+ log(`ok — stored memory (id=${factId})`);
262
+ }
263
+ }
264
+ catch (err) {
265
+ const msg = err instanceof Error ? err.message : String(err);
266
+ die(`remember failed: ${msg}`);
267
+ }
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // Command: recall
271
+ // ---------------------------------------------------------------------------
272
+ async function cmdRecall(rawArgs) {
273
+ const [jsonMode, argsAfterJson] = popFlag(rawArgs, '--json');
274
+ const [limit, argsAfterLimit] = popLimitFlag(argsAfterJson, 5);
275
+ const query = argsAfterLimit.join(' ').trim();
276
+ if (!query) {
277
+ die('Usage: tr recall [--json] [--limit N] <query>');
278
+ }
279
+ const ctx = await buildContext();
280
+ // Generate word trapdoors for blind search
281
+ const trapdoors = generateBlindIndices(query);
282
+ if (trapdoors.length === 0) {
283
+ if (jsonMode) {
284
+ log(JSON.stringify({ results: [] }));
285
+ }
286
+ else {
287
+ log('No results (0 searchable terms in query).');
288
+ }
289
+ return;
290
+ }
291
+ try {
292
+ const candidates = await ctx.apiClient.search(ctx.userId, trapdoors, Math.min(limit * 2, 20), ctx.authKeyHex);
293
+ const results = [];
294
+ for (const c of candidates) {
295
+ try {
296
+ const raw = decrypt(c.encrypted_blob, ctx.encryptionKey);
297
+ const parsed = JSON.parse(raw);
298
+ if (parsed.text) {
299
+ results.push({
300
+ text: parsed.text,
301
+ score: c.decay_score,
302
+ });
303
+ }
304
+ }
305
+ catch {
306
+ // Skip undecryptable
307
+ }
308
+ }
309
+ // Sort by score descending, then trim to limit
310
+ results.sort((a, b) => b.score - a.score);
311
+ const trimmed = results.slice(0, limit);
312
+ if (jsonMode) {
313
+ // JSON-first output for agent parsing — canonical format per spec
314
+ log(JSON.stringify({ results: trimmed }));
315
+ }
316
+ else {
317
+ log(`Found ${trimmed.length} result(s) for: ${query}`);
318
+ for (const r of trimmed) {
319
+ log(` [score=${r.score.toFixed(2)}] ${r.text}`);
320
+ }
321
+ }
322
+ }
323
+ catch (err) {
324
+ const msg = err instanceof Error ? err.message : String(err);
325
+ die(`recall failed: ${msg}`);
326
+ }
327
+ }
328
+ // ---------------------------------------------------------------------------
329
+ // Dispatch
330
+ // ---------------------------------------------------------------------------
331
+ async function main() {
332
+ const args = process.argv.slice(2);
333
+ const cmd = args[0];
334
+ switch (cmd) {
335
+ case 'status': {
336
+ const [jsonMode] = popFlag(args.slice(1), '--json');
337
+ await cmdStatus(jsonMode);
338
+ break;
339
+ }
340
+ case 'pair':
341
+ await cmdPair(args.slice(1));
342
+ break;
343
+ case 'remember':
344
+ await cmdRemember(args.slice(1));
345
+ break;
346
+ case 'recall':
347
+ await cmdRecall(args.slice(1));
348
+ break;
349
+ case undefined:
350
+ case '--help':
351
+ case '-h':
352
+ process.stdout.write(`TotalReclaw hybrid CLI v${PLUGIN_VERSION} (primary mode — OpenClaw 2026.5.2+)\n\n` +
353
+ 'Usage:\n' +
354
+ ' tr status [--json] — onboarding + plugin load state\n' +
355
+ ' tr pair [--json] — start a relay pairing session\n' +
356
+ ' tr remember [--json] <text> — store a memory\n' +
357
+ ' tr recall [--json] [--limit N] <query> — search memories (default limit: 5)\n\n' +
358
+ 'Flags:\n' +
359
+ ' --json Output machine-parseable JSON (required for agent shell calls)\n' +
360
+ ' --limit N Limit recall results (default: 5)\n\n' +
361
+ 'JSON output shapes:\n' +
362
+ ' status: {"version":"...","onboarded":bool,"next_step":"pair|none","tool_count":N,"hybrid_mode":bool}\n' +
363
+ ' pair: {"url":"...","pin":"123456","expires_at":"..."}\n' +
364
+ ' remember: {"ok":true,"id":"...","claim_count":N}\n' +
365
+ ' recall: {"results":[{"text":"...","score":0.8}]}\n\n' +
366
+ 'Environment:\n' +
367
+ ' TOTALRECLAW_SERVER_URL — relay URL (default: api-staging.totalreclaw.xyz)\n' +
368
+ ' TOTALRECLAW_CREDENTIALS_PATH — override credentials.json path\n');
369
+ break;
370
+ default:
371
+ die(`Unknown command: ${cmd}. Run \`tr --help\` for usage.`);
372
+ }
373
+ }
374
+ main().catch((err) => {
375
+ const msg = err instanceof Error ? err.message : String(err);
376
+ process.stderr.write(`tr: fatal: ${msg}\n`);
377
+ process.exit(2);
378
+ });
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,7 @@
2
2
  "id": "totalreclaw",
3
3
  "name": "TotalReclaw",
4
4
  "description": "End-to-end encrypted memory vault for AI agents",
5
+ "kind": "memory",
5
6
  "contracts": {
6
7
  "tools": [
7
8
  "totalreclaw_remember",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.7-rc.3",
3
+ "version": "3.3.9-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",
@@ -64,7 +67,7 @@
64
67
  "scripts": {
65
68
  "build": "rm -rf dist && tsc -p tsconfig.json --noCheck",
66
69
  "verify-tarball": "node ../scripts/verify-tarball.mjs",
67
- "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx config.test.ts && npx tsx relay-headers.test.ts && npx tsx scope-address-visible.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx pair-qr.test.ts && npx tsx pair-remote-client.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts && npx tsx test_issue_92_onnx_download_ux.test.ts && npx tsx onboard-pair-only.test.ts && npx tsx import-time-smoke.test.ts && npx tsx install-staging-cleanup.test.ts && npx tsx partial-install-detection.test.ts && npx tsx install-reload-idempotency.test.ts && npx tsx json-stdout-cleanliness.test.ts && npx tsx load-manifest.test.ts && npx tsx url-binding.test.ts && npx tsx fs-helpers.test.ts && npx tsx pair-cli-default-mode.test.ts && npx tsx embedding-fallback-tag.test.ts && npx tsx staging-banner-gate.test.ts && npx tsx restart-auth.test.ts && npx tsx inbound-user-tracker.test.ts && npx tsx register-command-name.test.ts",
70
+ "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx config.test.ts && npx tsx relay-headers.test.ts && npx tsx scope-address-visible.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx pair-qr.test.ts && npx tsx pair-remote-client.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts && npx tsx test_issue_92_onnx_download_ux.test.ts && npx tsx onboard-pair-only.test.ts && npx tsx import-time-smoke.test.ts && npx tsx install-staging-cleanup.test.ts && npx tsx partial-install-detection.test.ts && npx tsx install-reload-idempotency.test.ts && npx tsx json-stdout-cleanliness.test.ts && npx tsx load-manifest.test.ts && npx tsx url-binding.test.ts && npx tsx fs-helpers.test.ts && npx tsx pair-cli-default-mode.test.ts && npx tsx embedding-fallback-tag.test.ts && npx tsx staging-banner-gate.test.ts && npx tsx restart-auth.test.ts && npx tsx inbound-user-tracker.test.ts && npx tsx register-command-name.test.ts && npx tsx skill-md-hybrid-primary.test.ts && npx tsx tr-cli-json-output.test.ts",
68
71
  "smoke:dist": "npx tsx dist-esm-smoke.test.ts",
69
72
  "check-scanner": "node ../scripts/check-scanner.mjs",
70
73
  "check-version-drift": "node ../scripts/check-version-drift.mjs",
package/skill.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totalreclaw",
3
- "version": "3.3.7-rc.3",
3
+ "version": "3.3.9-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,445 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tr — TotalReclaw hybrid CLI (3.3.9-rc.1 primary architecture)
4
+ *
5
+ * OpenClaw 2026.5.2 has a tool-policy-pipeline bug (issue #223) that strips non-bundled plugin
6
+ * tools before they reach the agent toolset. In 3.3.9-rc.1, this CLI is the PRIMARY path for
7
+ * all agent memory operations (not a fallback). The agent runs `tr <cmd> --json` from shell;
8
+ * hooks (before_agent_start, agent_end, message_received, before_reset) continue via the
9
+ * unbroken hook code path.
10
+ *
11
+ * Phrase-safety: this CLI reads credentials.json (mnemonic at rest) but NEVER
12
+ * prints the mnemonic to stdout, stderr, or any log. Phrase only enters via QR-pair
13
+ * browser tier (pair-cli.ts / pair-cli-relay.ts — unchanged).
14
+ *
15
+ * Commands:
16
+ * tr status [--json] — print onboarding + credentials state
17
+ * tr pair [--json] — start a relay pairing session, print URL+PIN+QR
18
+ * tr remember [--json] <text> — store a memory in the encrypted vault
19
+ * tr recall [--json] [--limit N] <query> — search the encrypted vault
20
+ *
21
+ * --json flag: all agent-facing CLI calls MUST use --json for clean machine-parseable output.
22
+ * Plain text mode is for direct user CLI use only.
23
+ *
24
+ * Install: wired via package.json `bin.tr` → dist/tr-cli.js
25
+ * Usage from container: `docker exec tr-openclaw node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status --json`
26
+ */
27
+
28
+ import path from 'node:path';
29
+ import os from 'node:os';
30
+ import { randomUUID } from 'node:crypto';
31
+
32
+ import { CONFIG } from './config.js';
33
+ import { loadCredentialsJson } from './fs-helpers.js';
34
+ import { printStatus } from './onboarding-cli.js';
35
+ import {
36
+ deriveKeys,
37
+ computeAuthKeyHash,
38
+ encrypt,
39
+ decrypt,
40
+ generateBlindIndices,
41
+ generateContentFingerprint,
42
+ } from './crypto.js';
43
+ import { createApiClient } from './api-client.js';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const CREDENTIALS_PATH = CONFIG.credentialsPath;
50
+ const SERVER_URL = CONFIG.serverUrl;
51
+ const STATE_PATH = CONFIG.onboardingStatePath;
52
+ const PLUGIN_VERSION = '3.3.9-rc.1';
53
+
54
+ function die(msg: string, code = 1): never {
55
+ process.stderr.write(`tr: ${msg}\n`);
56
+ process.exit(code);
57
+ }
58
+
59
+ function log(msg: string): void {
60
+ process.stdout.write(msg + '\n');
61
+ }
62
+
63
+ /** Parse --flag from args array, returning the cleaned args without the flag. */
64
+ function popFlag(args: string[], flag: string): [boolean, string[]] {
65
+ const idx = args.indexOf(flag);
66
+ if (idx === -1) return [false, args];
67
+ return [true, [...args.slice(0, idx), ...args.slice(idx + 1)]];
68
+ }
69
+
70
+ /** Parse --limit N from args, returning [limit, cleanedArgs]. Default: defaultLimit. */
71
+ function popLimitFlag(args: string[], defaultLimit: number): [number, string[]] {
72
+ const idx = args.indexOf('--limit');
73
+ if (idx === -1 || idx + 1 >= args.length) return [defaultLimit, args];
74
+ const n = parseInt(args[idx + 1], 10);
75
+ const limit = isNaN(n) || n < 1 ? defaultLimit : n;
76
+ return [limit, [...args.slice(0, idx), ...args.slice(idx + 2)]];
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Core init — minimal version of index.ts initialize()
81
+ // ---------------------------------------------------------------------------
82
+
83
+ interface CliContext {
84
+ authKeyHex: string;
85
+ encryptionKey: Buffer;
86
+ dedupKey: Buffer;
87
+ apiClient: ReturnType<typeof createApiClient>;
88
+ userId: string;
89
+ }
90
+
91
+ async function buildContext(): Promise<CliContext> {
92
+ const creds = loadCredentialsJson(CREDENTIALS_PATH);
93
+ if (!creds) {
94
+ die('TotalReclaw is not set up. Run: node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js pair --json');
95
+ }
96
+
97
+ const mnemonic =
98
+ (typeof creds.mnemonic === 'string' && creds.mnemonic.trim()) ||
99
+ (typeof creds.recovery_phrase === 'string' && creds.recovery_phrase.trim()) ||
100
+ '';
101
+
102
+ if (!mnemonic) {
103
+ die('No recovery phrase in credentials.json. Run: tr pair --json');
104
+ }
105
+
106
+ // Parse existing salt/userId from credentials.json
107
+ let existingSalt: Buffer | undefined;
108
+ let existingUserId: string | undefined;
109
+
110
+ const saltStr = typeof creds.salt === 'string' ? creds.salt : undefined;
111
+ if (saltStr) {
112
+ if (/^[0-9a-f]{64}$/i.test(saltStr)) {
113
+ existingSalt = Buffer.from(saltStr, 'hex');
114
+ } else {
115
+ existingSalt = Buffer.from(saltStr, 'base64');
116
+ }
117
+ }
118
+ existingUserId = typeof creds.userId === 'string' ? creds.userId : undefined;
119
+
120
+ const keys = deriveKeys(mnemonic, existingSalt);
121
+ const authKeyHex = keys.authKey.toString('hex');
122
+
123
+ const apiClient = createApiClient(SERVER_URL);
124
+
125
+ let userId: string;
126
+ if (existingUserId) {
127
+ userId = existingUserId;
128
+ } else {
129
+ // Register to get userId (idempotent on relay)
130
+ const authHash = computeAuthKeyHash(keys.authKey);
131
+ const saltHex = keys.salt.toString('hex');
132
+ try {
133
+ const result = await apiClient.register(authHash, saltHex);
134
+ userId = result.user_id;
135
+ } catch (err) {
136
+ const msg = err instanceof Error ? err.message : String(err);
137
+ if (msg.includes('USER_EXISTS')) {
138
+ userId = authHash.slice(0, 32);
139
+ } else {
140
+ die(`Relay registration failed: ${msg}`);
141
+ }
142
+ }
143
+ }
144
+
145
+ return {
146
+ authKeyHex,
147
+ encryptionKey: keys.encryptionKey,
148
+ dedupKey: keys.dedupKey,
149
+ apiClient,
150
+ userId,
151
+ };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Command: status
156
+ // ---------------------------------------------------------------------------
157
+
158
+ async function cmdStatus(jsonMode: boolean): Promise<void> {
159
+ // Probe plugin manifest for version/hybridMode/toolCount.
160
+ let pluginVersion: string | undefined;
161
+ let bootCount: number | undefined;
162
+ let hybridMode = true; // default true in 3.3.9-rc.1 (hybrid-primary)
163
+ let toolCount: number | undefined;
164
+ let loadedAgeSec: number | undefined;
165
+
166
+ try {
167
+ const fs = await import('node:fs');
168
+ const candidatePaths = [
169
+ path.join(os.homedir(), '.openclaw', 'extensions', 'totalreclaw', '.loaded.json'),
170
+ path.join(os.homedir(), '.openclaw', 'npm', 'node_modules', '@totalreclaw', 'totalreclaw', 'dist', '.loaded.json'),
171
+ ];
172
+ const resolvedPath = candidatePaths.find((p) => fs.existsSync(p));
173
+ if (resolvedPath) {
174
+ const raw = fs.readFileSync(resolvedPath, 'utf-8');
175
+ const manifest = JSON.parse(raw) as {
176
+ version?: string;
177
+ bootCount?: number;
178
+ loadedAt?: number;
179
+ hybridMode?: boolean;
180
+ tools?: string[];
181
+ };
182
+ pluginVersion = manifest.version ?? PLUGIN_VERSION;
183
+ bootCount = manifest.bootCount;
184
+ hybridMode = manifest.hybridMode !== false; // default true
185
+ toolCount = manifest.tools?.length;
186
+ const ageMs = Date.now() - (manifest.loadedAt ?? 0);
187
+ loadedAgeSec = Math.round(ageMs / 1000);
188
+ }
189
+ } catch {
190
+ // Best-effort
191
+ }
192
+
193
+ // Check onboarding state
194
+ const creds = loadCredentialsJson(CREDENTIALS_PATH);
195
+ const onboarded = !!creds;
196
+
197
+ if (jsonMode) {
198
+ // JSON-first output for agent parsing
199
+ const out: Record<string, unknown> = {
200
+ version: pluginVersion ?? PLUGIN_VERSION,
201
+ onboarded,
202
+ next_step: onboarded ? 'none' : 'pair',
203
+ tool_count: toolCount ?? 17,
204
+ hybrid_mode: hybridMode,
205
+ };
206
+ if (bootCount !== undefined) out.boot_count = bootCount;
207
+ if (loadedAgeSec !== undefined) out.loaded_age_sec = loadedAgeSec;
208
+ log(JSON.stringify(out));
209
+ } else {
210
+ // Human-readable plain text for direct user CLI use
211
+ printStatus(CREDENTIALS_PATH, STATE_PATH, process.stdout);
212
+ process.stdout.write(
213
+ `\n plugin: ${pluginVersion ? `loaded (version=${pluginVersion}` + (bootCount !== undefined ? ` bootCount=${bootCount}` : '') + (loadedAgeSec !== undefined ? ` loaded=${loadedAgeSec}s ago` : '') + ')' : 'not found in .loaded.json'}\n` +
214
+ ` hybrid-mode: ${hybridMode ? 'yes (primary — use tr <cmd> --json)' : 'no'}\n` +
215
+ ` hooks: before_agent_start, agent_end, message_received, before_reset\n`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Command: pair
222
+ // ---------------------------------------------------------------------------
223
+
224
+ async function cmdPair(args: string[]): Promise<void> {
225
+ // Delegate to the existing pair-cli-relay.ts via a thin wrapper.
226
+ // The pair flow is relay-brokered (works through Docker NAT).
227
+ // Phrase-safety: pair-cli-relay.ts is x25519-only; mnemonic never appears.
228
+ const outputMode = args.includes('--json') ? 'json' : args.includes('--url-pin') ? 'url-pin' : 'human';
229
+
230
+ const { runRelayPairCli } = await import('./pair-cli-relay.js');
231
+ const { defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
232
+
233
+ const io = buildDefaultPairCliIo();
234
+ const outcome = await runRelayPairCli('generate', {
235
+ relayBaseUrl: CONFIG.pairRelayUrl,
236
+ credentialsPath: CREDENTIALS_PATH,
237
+ onboardingStatePath: STATE_PATH,
238
+ logger: {
239
+ info: (m: string) => process.stderr.write(`[info] ${m}\n`),
240
+ warn: (m: string) => process.stderr.write(`[warn] ${m}\n`),
241
+ error: (m: string) => process.stderr.write(`[error] ${m}\n`),
242
+ },
243
+ pluginVersion: PLUGIN_VERSION,
244
+ deriveScopeAddress: undefined,
245
+ renderQr: defaultRenderQr,
246
+ io,
247
+ outputMode: outputMode as import('./pair-cli.js').PairCliOutputMode,
248
+ });
249
+
250
+ if (outcome.status !== 'completed' && outcome.status !== 'canceled') {
251
+ die(`Pairing ${outcome.status}`, 1);
252
+ }
253
+ if (outcome.status === 'canceled') {
254
+ process.exit(130);
255
+ }
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Command: remember
260
+ // ---------------------------------------------------------------------------
261
+
262
+ async function cmdRemember(rawArgs: string[]): Promise<void> {
263
+ const [jsonMode, args] = popFlag(rawArgs, '--json');
264
+ const text = args.join(' ').trim();
265
+ if (!text) {
266
+ die('Usage: tr remember [--json] <text>');
267
+ }
268
+
269
+ const ctx = await buildContext();
270
+
271
+ // Build a minimal MemoryTaxonomy v1 claim blob (same format as storeExtractedFacts)
272
+ const now = new Date().toISOString();
273
+ const factId = randomUUID().replace(/-/g, '');
274
+
275
+ // Encrypt the memory text
276
+ const blob = JSON.stringify({
277
+ text,
278
+ type: 'claim',
279
+ source: 'user',
280
+ scope: 'unspecified',
281
+ importance: 8,
282
+ metadata: {
283
+ type: 'claim',
284
+ source: 'user',
285
+ scope: 'unspecified',
286
+ importance: 8,
287
+ },
288
+ timestamp: now,
289
+ version: 'v1',
290
+ });
291
+ const encrypted_blob = encrypt(blob, ctx.encryptionKey);
292
+ const blind_indices = generateBlindIndices(text);
293
+ const content_fp = generateContentFingerprint(text, ctx.dedupKey);
294
+
295
+ const payload = {
296
+ id: factId,
297
+ timestamp: now,
298
+ encrypted_blob,
299
+ blind_indices,
300
+ decay_score: 8,
301
+ source: 'cli:tr-remember',
302
+ content_fp,
303
+ };
304
+
305
+ try {
306
+ await ctx.apiClient.store(ctx.userId, [payload], ctx.authKeyHex);
307
+ if (jsonMode) {
308
+ // JSON-first output for agent parsing
309
+ // claim_count requires an extra relay call to tally stored claims; not worth the latency — use 0
310
+ log(JSON.stringify({ ok: true, id: factId, claim_count: 0 }));
311
+ } else {
312
+ log(`ok — stored memory (id=${factId})`);
313
+ }
314
+ } catch (err) {
315
+ const msg = err instanceof Error ? err.message : String(err);
316
+ die(`remember failed: ${msg}`);
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Command: recall
322
+ // ---------------------------------------------------------------------------
323
+
324
+ async function cmdRecall(rawArgs: string[]): Promise<void> {
325
+ const [jsonMode, argsAfterJson] = popFlag(rawArgs, '--json');
326
+ const [limit, argsAfterLimit] = popLimitFlag(argsAfterJson, 5);
327
+ const query = argsAfterLimit.join(' ').trim();
328
+ if (!query) {
329
+ die('Usage: tr recall [--json] [--limit N] <query>');
330
+ }
331
+
332
+ const ctx = await buildContext();
333
+
334
+ // Generate word trapdoors for blind search
335
+ const trapdoors = generateBlindIndices(query);
336
+
337
+ if (trapdoors.length === 0) {
338
+ if (jsonMode) {
339
+ log(JSON.stringify({ results: [] }));
340
+ } else {
341
+ log('No results (0 searchable terms in query).');
342
+ }
343
+ return;
344
+ }
345
+
346
+ try {
347
+ const candidates = await ctx.apiClient.search(ctx.userId, trapdoors, Math.min(limit * 2, 20), ctx.authKeyHex);
348
+
349
+ const results: Array<{ text: string; score: number }> = [];
350
+
351
+ for (const c of candidates) {
352
+ try {
353
+ const raw = decrypt(c.encrypted_blob, ctx.encryptionKey);
354
+ const parsed = JSON.parse(raw) as { text?: string };
355
+ if (parsed.text) {
356
+ results.push({
357
+ text: parsed.text,
358
+ score: c.decay_score,
359
+ });
360
+ }
361
+ } catch {
362
+ // Skip undecryptable
363
+ }
364
+ }
365
+
366
+ // Sort by score descending, then trim to limit
367
+ results.sort((a, b) => b.score - a.score);
368
+ const trimmed = results.slice(0, limit);
369
+
370
+ if (jsonMode) {
371
+ // JSON-first output for agent parsing — canonical format per spec
372
+ log(JSON.stringify({ results: trimmed }));
373
+ } else {
374
+ log(`Found ${trimmed.length} result(s) for: ${query}`);
375
+ for (const r of trimmed) {
376
+ log(` [score=${r.score.toFixed(2)}] ${r.text}`);
377
+ }
378
+ }
379
+ } catch (err) {
380
+ const msg = err instanceof Error ? err.message : String(err);
381
+ die(`recall failed: ${msg}`);
382
+ }
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Dispatch
387
+ // ---------------------------------------------------------------------------
388
+
389
+ async function main(): Promise<void> {
390
+ const args = process.argv.slice(2);
391
+ const cmd = args[0];
392
+
393
+ switch (cmd) {
394
+ case 'status': {
395
+ const [jsonMode] = popFlag(args.slice(1), '--json');
396
+ await cmdStatus(jsonMode);
397
+ break;
398
+ }
399
+
400
+ case 'pair':
401
+ await cmdPair(args.slice(1));
402
+ break;
403
+
404
+ case 'remember':
405
+ await cmdRemember(args.slice(1));
406
+ break;
407
+
408
+ case 'recall':
409
+ await cmdRecall(args.slice(1));
410
+ break;
411
+
412
+ case undefined:
413
+ case '--help':
414
+ case '-h':
415
+ process.stdout.write(
416
+ `TotalReclaw hybrid CLI v${PLUGIN_VERSION} (primary mode — OpenClaw 2026.5.2+)\n\n` +
417
+ 'Usage:\n' +
418
+ ' tr status [--json] — onboarding + plugin load state\n' +
419
+ ' tr pair [--json] — start a relay pairing session\n' +
420
+ ' tr remember [--json] <text> — store a memory\n' +
421
+ ' tr recall [--json] [--limit N] <query> — search memories (default limit: 5)\n\n' +
422
+ 'Flags:\n' +
423
+ ' --json Output machine-parseable JSON (required for agent shell calls)\n' +
424
+ ' --limit N Limit recall results (default: 5)\n\n' +
425
+ 'JSON output shapes:\n' +
426
+ ' status: {"version":"...","onboarded":bool,"next_step":"pair|none","tool_count":N,"hybrid_mode":bool}\n' +
427
+ ' pair: {"url":"...","pin":"123456","expires_at":"..."}\n' +
428
+ ' remember: {"ok":true,"id":"...","claim_count":N}\n' +
429
+ ' recall: {"results":[{"text":"...","score":0.8}]}\n\n' +
430
+ 'Environment:\n' +
431
+ ' TOTALRECLAW_SERVER_URL — relay URL (default: api-staging.totalreclaw.xyz)\n' +
432
+ ' TOTALRECLAW_CREDENTIALS_PATH — override credentials.json path\n',
433
+ );
434
+ break;
435
+
436
+ default:
437
+ die(`Unknown command: ${cmd}. Run \`tr --help\` for usage.`);
438
+ }
439
+ }
440
+
441
+ main().catch((err) => {
442
+ const msg = err instanceof Error ? err.message : String(err);
443
+ process.stderr.write(`tr: fatal: ${msg}\n`);
444
+ process.exit(2);
445
+ });