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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. package/subgraph-store.ts +109 -16
@@ -0,0 +1,344 @@
1
+ /**
2
+ * pair-cli — the `openclaw totalreclaw pair` CLI subcommand.
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * Starts a remote-onboarding session FROM the gateway host's terminal.
7
+ * Creates a pair-session, renders the QR + URL + 6-digit secondary code
8
+ * to stdout, then polls /status until the browser completes the flow.
9
+ *
10
+ * This is the gateway-operator's surface. The operator reads the QR
11
+ * with their phone (or opens the URL on their laptop browser); the
12
+ * browser takes over from there.
13
+ *
14
+ * Scope and scanner surface
15
+ * -------------------------
16
+ * Has `fetch` (for status polling) AND `POST` (never actually POSTs,
17
+ * but the word lives in comments describing the paired browser POST).
18
+ * MUST NOT also read disk or env vars. All state operations delegate
19
+ * to pair-session-store; the CLI itself is a thin coordinator.
20
+ *
21
+ * Zero logging of secret material. The secondary code IS printed to
22
+ * stdout (required for the user to type), but never logged to file
23
+ * and never to api.logger.
24
+ */
25
+ import readline from 'node:readline';
26
+ import { createPairSession, getPairSession, rejectPairSession, } from './pair-session-store.js';
27
+ import { generateGatewayKeypair } from './pair-crypto.js';
28
+ // ---------------------------------------------------------------------------
29
+ // Default stdout IO
30
+ // ---------------------------------------------------------------------------
31
+ export function buildDefaultPairCliIo() {
32
+ return {
33
+ stdout: process.stdout,
34
+ stderr: process.stderr,
35
+ onInterrupt(cb) {
36
+ const handler = () => {
37
+ try {
38
+ cb();
39
+ }
40
+ catch { /* swallow */ }
41
+ };
42
+ process.once('SIGINT', handler);
43
+ return () => process.off('SIGINT', handler);
44
+ },
45
+ };
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Copy — same security principles as onboarding-cli COPY but terser.
49
+ // ---------------------------------------------------------------------------
50
+ const COPY = {
51
+ intro: '\nTotalReclaw — Remote pairing\n\n' +
52
+ 'Your TotalReclaw recovery phrase will be created (or imported) in your\n' +
53
+ 'BROWSER and delivered to this gateway encrypted end-to-end. The phrase\n' +
54
+ 'never touches the LLM, the session transcript, or the relay server\n' +
55
+ 'in plaintext.\n\n' +
56
+ 'Scan the QR code below with your phone, or open the URL on any\n' +
57
+ 'device. Then type the 6-digit code shown here into the browser.\n',
58
+ introGenerate: '\nMode: GENERATE — your browser will create a NEW 12-word recovery phrase.\n' +
59
+ 'You will be asked to write it down and retype 3 words before the\n' +
60
+ 'gateway accepts it.\n',
61
+ introImport: '\nMode: IMPORT — your browser will accept an existing TotalReclaw\n' +
62
+ 'recovery phrase that you already have. Paste it in the browser; it\n' +
63
+ 'will be validated locally and encrypted before upload.\n',
64
+ codeLabel: '\nSecondary code (type this into the browser):\n\n ',
65
+ urlLabel: '\n\nURL (QR encodes this plus a one-time public key):\n\n ',
66
+ securityWarning: '\n\nSecurity:\n' +
67
+ ' * Do NOT share your screen during pairing.\n' +
68
+ ' * Do NOT screenshot this terminal.\n' +
69
+ ' * The browser page will warn you never to reuse this recovery\n' +
70
+ ' phrase for wallets, banking, email, or any other service.\n',
71
+ awaiting: '\nWaiting for browser to connect… (press Ctrl+C to cancel)',
72
+ deviceConnected: '\nBrowser connected. Waiting for encrypted payload…',
73
+ completed: '\nPairing complete. Account is active.',
74
+ canceled: '\nCanceled. Pairing session invalidated.',
75
+ expired: '\nSession expired. Run the command again to restart.',
76
+ rejected: '\nPairing rejected (too many wrong codes, or gateway aborted).',
77
+ };
78
+ function renderUnsafelyVisibleCode(code) {
79
+ // Pad digits with spaces so terminal copy-paste can't accidentally
80
+ // pick them up as a single token.
81
+ return code.split('').join(' ');
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Public entry point
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * Start a pairing session, display the QR + code + URL, and poll
88
+ * until terminal state. Returns the final outcome.
89
+ *
90
+ * Blocks until the session finishes, expires, or the operator hits
91
+ * Ctrl+C.
92
+ *
93
+ * 3.3.1 — Non-TTY support:
94
+ * - Does NOT call `readline` / `stdin.setRawMode` / any interactive
95
+ * prompt. All output is unidirectional to stdout/stderr, so the
96
+ * command works under `docker exec <container> ...` without `-t`.
97
+ * - Adds an optional JSON mode (deps.outputMode === 'json') that emits
98
+ * a single JSON object to stdout before polling begins. Agents
99
+ * capture it, present the QR / URL / PIN to the user themselves,
100
+ * and still get the terminal-state exit code.
101
+ */
102
+ export async function runPairCli(mode, deps) {
103
+ const now = deps.now ?? Date.now;
104
+ const pollInterval = Math.max(500, deps.pollIntervalMs ?? 1500);
105
+ const io = deps.io;
106
+ const stdout = io.stdout;
107
+ const outputMode = deps.outputMode ?? 'human';
108
+ // 1. Generate keypair + create the session
109
+ const kp = generateGatewayKeypair();
110
+ let session;
111
+ try {
112
+ session = await createPairSession(deps.sessionsPath, {
113
+ mode,
114
+ operatorContext: { channel: 'cli' },
115
+ ttlMs: deps.ttlSeconds !== undefined ? deps.ttlSeconds * 1000 : undefined,
116
+ rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
117
+ rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
118
+ now,
119
+ });
120
+ }
121
+ catch (err) {
122
+ const msg = err instanceof Error ? err.message : String(err);
123
+ io.stderr.write(`\nFailed to create pairing session: ${msg}\n`);
124
+ return { status: 'error', error: msg };
125
+ }
126
+ // 2. Build the URL unconditionally, but only render the QR for modes
127
+ // that actually emit it. url-pin and pair-only modes skip the
128
+ // renderer entirely — no CPU cost, no qrcode-terminal import, no
129
+ // ASCII on stdout.
130
+ const url = deps.renderPairingUrl(session);
131
+ const skipsQr = outputMode === 'url-pin' || outputMode === 'pair-only';
132
+ const qrAscii = skipsQr ? '' : await new Promise((resolve) => {
133
+ // Guard against QR renderers that never fire their callback (shouldn't
134
+ // happen with qrcode-terminal, but defensive): a 10-second timeout
135
+ // returns an empty string so we never hang the pairing flow.
136
+ let settled = false;
137
+ const t = setTimeout(() => {
138
+ if (!settled) {
139
+ settled = true;
140
+ resolve('');
141
+ }
142
+ }, 10_000);
143
+ try {
144
+ deps.renderQr(url, (ascii) => {
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ clearTimeout(t);
149
+ resolve(ascii);
150
+ });
151
+ }
152
+ catch (err) {
153
+ if (settled)
154
+ return;
155
+ settled = true;
156
+ clearTimeout(t);
157
+ resolve(`(QR renderer crashed: ${err instanceof Error ? err.message : String(err)})`);
158
+ }
159
+ });
160
+ // 3. Emit the visible surface (JSON/url-pin/pair-only first — single
161
+ // line — or human copy).
162
+ if (outputMode === 'url-pin') {
163
+ const payload = {
164
+ v: 1,
165
+ url,
166
+ pin: session.secondaryCode,
167
+ expires_at_ms: session.expiresAtMs,
168
+ };
169
+ stdout.write(JSON.stringify(payload) + '\n');
170
+ }
171
+ else if (outputMode === 'pair-only') {
172
+ const payload = {
173
+ v: 1,
174
+ pair_url: url,
175
+ pin: session.secondaryCode,
176
+ expires_at_ms: session.expiresAtMs,
177
+ };
178
+ stdout.write(JSON.stringify(payload) + '\n');
179
+ }
180
+ else if (outputMode === 'json') {
181
+ const payload = {
182
+ v: 1,
183
+ sid: session.sid,
184
+ url,
185
+ pin: session.secondaryCode,
186
+ mode,
187
+ expires_at_ms: session.expiresAtMs,
188
+ qr_ascii: qrAscii,
189
+ };
190
+ stdout.write(JSON.stringify(payload) + '\n');
191
+ }
192
+ else {
193
+ stdout.write(COPY.intro);
194
+ stdout.write(mode === 'generate' ? COPY.introGenerate : COPY.introImport);
195
+ if (qrAscii) {
196
+ stdout.write('\n' + qrAscii + '\n');
197
+ }
198
+ else {
199
+ stdout.write('\n(QR not rendered — use the URL below)\n');
200
+ }
201
+ stdout.write(COPY.codeLabel);
202
+ stdout.write(renderUnsafelyVisibleCode(session.secondaryCode));
203
+ stdout.write(COPY.urlLabel);
204
+ stdout.write(url);
205
+ stdout.write(COPY.securityWarning);
206
+ stdout.write(COPY.awaiting);
207
+ stdout.write('\n');
208
+ }
209
+ // 4. Set up Ctrl+C to cancel the session server-side
210
+ let canceled = false;
211
+ const releaseInterrupt = io.onInterrupt(() => {
212
+ canceled = true;
213
+ });
214
+ // 5. Poll — status transitions only surface in human mode; json /
215
+ // url-pin / pair-only modes stay silent after the single payload
216
+ // line so agents parsing stdout get one JSON line and an exit
217
+ // code, nothing else.
218
+ const emitStatus = (text) => {
219
+ if (outputMode === 'human')
220
+ stdout.write(text);
221
+ };
222
+ let lastStatus = session.status;
223
+ let showedDeviceConnected = false;
224
+ try {
225
+ while (true) {
226
+ if (canceled) {
227
+ await rejectPairSession(deps.sessionsPath, session.sid, now);
228
+ emitStatus(COPY.canceled + '\n');
229
+ return { status: 'canceled', sid: session.sid };
230
+ }
231
+ await sleep(pollInterval);
232
+ const fresh = await getPairSession(deps.sessionsPath, session.sid, now);
233
+ if (!fresh) {
234
+ // Pruned — session is gone entirely.
235
+ emitStatus(COPY.expired + '\n');
236
+ return { status: 'expired', sid: session.sid };
237
+ }
238
+ if (fresh.status !== lastStatus) {
239
+ lastStatus = fresh.status;
240
+ if (fresh.status === 'device_connected' && !showedDeviceConnected) {
241
+ emitStatus(COPY.deviceConnected + '\n');
242
+ showedDeviceConnected = true;
243
+ }
244
+ }
245
+ if (fresh.status === 'completed') {
246
+ emitStatus(COPY.completed + '\n');
247
+ return { status: 'completed', sid: session.sid };
248
+ }
249
+ if (fresh.status === 'expired') {
250
+ emitStatus(COPY.expired + '\n');
251
+ return { status: 'expired', sid: session.sid };
252
+ }
253
+ if (fresh.status === 'rejected') {
254
+ emitStatus(COPY.rejected + '\n');
255
+ return { status: 'rejected', sid: session.sid };
256
+ }
257
+ }
258
+ }
259
+ finally {
260
+ releaseInterrupt();
261
+ }
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // Wrap qrcode-terminal in a promise-friendly renderer. Dynamic import
265
+ // keeps the module out of the plugin's register() hot path.
266
+ // ---------------------------------------------------------------------------
267
+ /**
268
+ * Default QR renderer using `qrcode-terminal`. Lazy-imports so the
269
+ * module only loads when the CLI is actually invoked.
270
+ */
271
+ export function defaultRenderQr(payload, cb) {
272
+ import('qrcode-terminal').then((rawMod) => {
273
+ const mod = rawMod;
274
+ const qr = mod.default ?? mod;
275
+ qr.generate(payload, { small: true }, cb);
276
+ }).catch((err) => {
277
+ cb(`(QR renderer unavailable: ${err instanceof Error ? err.message : String(err)})`);
278
+ });
279
+ }
280
+ export function registerPairCli(program, deps) {
281
+ // If the onboarding-cli already attached `totalreclaw`, reuse it.
282
+ // Otherwise create a fresh top-level command.
283
+ let tr = program.commands.find((c) => c.name() === 'totalreclaw');
284
+ if (!tr) {
285
+ tr = program
286
+ .command('totalreclaw')
287
+ .description('TotalReclaw encrypted memory — pairing + onboarding + status');
288
+ }
289
+ tr.command('pair [mode]')
290
+ .description('Pair a remote browser device to this gateway (mode = generate | import; default generate)')
291
+ .option('--json', 'Emit a single JSON payload (url/pin/sid/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
292
+ .option('--url-pin-only', 'Emit ONLY {v,url,pin,expires_at_ms} — no QR ASCII, no SID, no mode echo. Headless fallback for container-based agents where the totalreclaw_pair tool is not injected (issue #87). Zero phrase exposure on stdout.')
293
+ .option('--timeout <sec>', 'Session TTL in seconds (default: 900 = 15 min, matches pair-session-store default)')
294
+ .action(async (...args) => {
295
+ // commander passes: [modeArg, options, cmd]
296
+ const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
297
+ const opts = (args[1] ?? {});
298
+ const mode = modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
299
+ // --url-pin-only wins over --json when both are passed, since it is
300
+ // strictly the tighter surface (no QR, no SID). The flag is a subset.
301
+ const outputMode = opts.urlPinOnly
302
+ ? 'url-pin'
303
+ : opts.json ? 'json' : 'human';
304
+ let ttlSeconds;
305
+ if (typeof opts.timeout === 'number' && Number.isFinite(opts.timeout)) {
306
+ ttlSeconds = opts.timeout;
307
+ }
308
+ else if (typeof opts.timeout === 'string' && opts.timeout.trim() !== '') {
309
+ const parsed = Number(opts.timeout);
310
+ if (Number.isFinite(parsed) && parsed > 0)
311
+ ttlSeconds = parsed;
312
+ }
313
+ const io = buildDefaultPairCliIo();
314
+ try {
315
+ const outcome = await runPairCli(mode, {
316
+ sessionsPath: deps.sessionsPath,
317
+ renderPairingUrl: deps.renderPairingUrl,
318
+ renderQr: defaultRenderQr,
319
+ io,
320
+ outputMode,
321
+ ttlSeconds,
322
+ });
323
+ if (outcome.status !== 'completed') {
324
+ process.exit(outcome.status === 'canceled' ? 130 : 1);
325
+ }
326
+ }
327
+ catch (err) {
328
+ const msg = err instanceof Error ? err.message : String(err);
329
+ deps.logger.error(`pair-cli crashed: ${msg}`);
330
+ process.exit(2);
331
+ }
332
+ });
333
+ }
334
+ // ---------------------------------------------------------------------------
335
+ // Utils
336
+ // ---------------------------------------------------------------------------
337
+ function sleep(ms) {
338
+ return new Promise((resolve) => setTimeout(resolve, ms));
339
+ }
340
+ // Keep readline import reachable (pair-cli doesn't use it directly yet,
341
+ // but future interactive prompts will land here; prevents tree-shaking
342
+ // from dropping a future dep). TypeScript requires the import to have
343
+ // an effect.
344
+ void readline;