@totalreclaw/totalreclaw 3.3.1-rc.9 → 3.3.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +249 -1
  2. package/SKILL.md +29 -23
  3. package/api-client.ts +18 -11
  4. package/claims-helper.ts +47 -1
  5. package/config.ts +108 -4
  6. package/confirm-indexed.ts +191 -0
  7. package/crypto.ts +10 -2
  8. package/dist/api-client.js +226 -0
  9. package/dist/billing-cache.js +100 -0
  10. package/dist/claims-helper.js +624 -0
  11. package/dist/config.js +297 -0
  12. package/dist/confirm-indexed.js +127 -0
  13. package/dist/consolidation.js +258 -0
  14. package/dist/contradiction-sync.js +1034 -0
  15. package/dist/crypto.js +138 -0
  16. package/dist/digest-sync.js +361 -0
  17. package/dist/download-ux.js +63 -0
  18. package/dist/embedder-cache.js +185 -0
  19. package/dist/embedder-loader.js +121 -0
  20. package/dist/embedder-network.js +301 -0
  21. package/dist/embedding.js +141 -0
  22. package/dist/extractor.js +1225 -0
  23. package/dist/first-run.js +103 -0
  24. package/dist/fs-helpers.js +725 -0
  25. package/dist/gateway-url.js +197 -0
  26. package/dist/generate-mnemonic.js +13 -0
  27. package/dist/hot-cache-wrapper.js +101 -0
  28. package/dist/import-adapters/base-adapter.js +64 -0
  29. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  30. package/dist/import-adapters/claude-adapter.js +114 -0
  31. package/dist/import-adapters/gemini-adapter.js +201 -0
  32. package/dist/import-adapters/index.js +26 -0
  33. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  34. package/dist/import-adapters/mem0-adapter.js +158 -0
  35. package/dist/import-adapters/types.js +1 -0
  36. package/dist/index.js +5388 -0
  37. package/dist/llm-client.js +687 -0
  38. package/dist/llm-profile-reader.js +346 -0
  39. package/dist/lsh.js +62 -0
  40. package/dist/onboarding-cli.js +750 -0
  41. package/dist/pair-cli.js +344 -0
  42. package/dist/pair-crypto.js +359 -0
  43. package/dist/pair-http.js +404 -0
  44. package/dist/pair-page.js +826 -0
  45. package/dist/pair-qr.js +107 -0
  46. package/dist/pair-remote-client.js +410 -0
  47. package/dist/pair-session-store.js +566 -0
  48. package/dist/pin.js +556 -0
  49. package/dist/qa-bug-report.js +301 -0
  50. package/dist/relay-headers.js +44 -0
  51. package/dist/reranker.js +409 -0
  52. package/dist/retype-setscope.js +368 -0
  53. package/dist/semantic-dedup.js +75 -0
  54. package/dist/subgraph-search.js +289 -0
  55. package/dist/subgraph-store.js +694 -0
  56. package/dist/tool-gating.js +58 -0
  57. package/download-ux.ts +91 -0
  58. package/embedder-cache.ts +230 -0
  59. package/embedder-loader.ts +189 -0
  60. package/embedder-network.ts +350 -0
  61. package/embedding.ts +118 -27
  62. package/fs-helpers.ts +277 -0
  63. package/gateway-url.ts +57 -9
  64. package/index.ts +469 -250
  65. package/llm-client.ts +4 -3
  66. package/lsh.ts +7 -2
  67. package/onboarding-cli.ts +114 -1
  68. package/package.json +24 -5
  69. package/pair-cli.ts +76 -8
  70. package/pair-crypto.ts +34 -24
  71. package/pair-page.ts +28 -17
  72. package/pair-qr.ts +152 -0
  73. package/pair-remote-client.ts +540 -0
  74. package/pin.ts +31 -0
  75. package/qa-bug-report.ts +84 -2
  76. package/relay-headers.ts +50 -0
  77. package/reranker.ts +40 -0
  78. package/retype-setscope.ts +69 -8
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +4 -3
  81. package/subgraph-store.ts +15 -10
package/fs-helpers.ts CHANGED
@@ -56,6 +56,15 @@ export interface CredentialsFile {
56
56
  mnemonic?: string;
57
57
  /** Alias for `mnemonic`, accepted on read only. */
58
58
  recovery_phrase?: string;
59
+ /**
60
+ * Smart Account (scope) address derived from the mnemonic. Persisted at
61
+ * pair-finish so users + tools (`totalreclaw_status`) can read it before
62
+ * any on-chain write. Internal#130 — lazy SA derivation previously left
63
+ * the user blind to their scope address until first-write.
64
+ *
65
+ * Format: lowercase 0x-prefixed 40-hex-char address. Public, non-secret.
66
+ */
67
+ scope_address?: string;
59
68
  firstRunAnnouncementShown?: boolean;
60
69
  [extra: string]: unknown;
61
70
  }
@@ -252,6 +261,274 @@ export function deleteFileIfExists(filePath: string): void {
252
261
  }
253
262
  }
254
263
 
264
+ // ---------------------------------------------------------------------------
265
+ // Install-staging cleanup (issue #126 — rc.20 finding F3)
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * Clean up `.openclaw-install-stage-*` sibling directories left behind by
270
+ * an interrupted `openclaw plugins install` run.
271
+ *
272
+ * Background
273
+ * ----------
274
+ * `openclaw plugins install @totalreclaw/totalreclaw` extracts the npm
275
+ * tarball into a staging directory named
276
+ * `<extensionsDir>/.openclaw-install-stage-XXXXXX/` and then renames it
277
+ * to `<extensionsDir>/totalreclaw/` on success. If the install is
278
+ * interrupted partway through (e.g. an auto-gateway-restart triggered by
279
+ * the same install kills the process — see rc.20 QA finding F3), the
280
+ * staging dir survives. On the next gateway start, OpenClaw's plugin
281
+ * loader auto-discovers BOTH directories — the real `totalreclaw/` and
282
+ * the orphaned `.openclaw-install-stage-XXXXXX/` — and registers two
283
+ * copies of the plugin. Hooks fire twice, the user sees a duplicate
284
+ * `totalreclaw` row in `openclaw plugins list`, and the gateway log
285
+ * spams a duplicate-plugin-id warning every cycle.
286
+ *
287
+ * Fix scope: best-effort cleanup driven by the plugin itself at register
288
+ * time. We resolve the extensions dir as the parent of the loaded
289
+ * plugin's own directory, scan for `.openclaw-install-stage-*` siblings,
290
+ * and recursively remove each one. If anything fails (permission,
291
+ * race with a concurrent install), we swallow the error — the existing
292
+ * loader-warning behavior is no worse than before.
293
+ *
294
+ * Returns the list of staging-dir paths that were successfully removed.
295
+ * Callers may log this for ops visibility. Empty list on a clean install.
296
+ *
297
+ * Parameters
298
+ * ----------
299
+ * @param pluginDir Absolute path to the loaded plugin's directory
300
+ * (typically `<extensionsDir>/totalreclaw/dist`). The
301
+ * helper walks up to the parent that holds sibling
302
+ * plugin directories (the `extensions/` root).
303
+ * @param _now Optional clock injector for testing — defaults to
304
+ * Date.now().
305
+ */
306
+ export function cleanupInstallStagingDirs(
307
+ pluginDir: string,
308
+ _now: () => number = Date.now,
309
+ ): string[] {
310
+ const removed: string[] = [];
311
+ try {
312
+ // pluginDir is `<extensionsDir>/totalreclaw/dist` after build, so the
313
+ // siblings live two levels up. Resolve both candidates so the helper
314
+ // works regardless of whether the caller passes the package root or
315
+ // its `dist/` subdir.
316
+ const candidates = [
317
+ path.resolve(pluginDir, '..'), // <extensionsDir>/totalreclaw → siblings dir if pluginDir is `dist`
318
+ path.resolve(pluginDir, '..', '..'), // <extensionsDir>/ → siblings dir if pluginDir is package root
319
+ ];
320
+
321
+ for (const extensionsDir of candidates) {
322
+ let entries: string[];
323
+ try {
324
+ entries = fs.readdirSync(extensionsDir);
325
+ } catch {
326
+ continue;
327
+ }
328
+ for (const name of entries) {
329
+ if (!name.startsWith('.openclaw-install-stage-')) continue;
330
+ const target = path.join(extensionsDir, name);
331
+ try {
332
+ const st = fs.lstatSync(target);
333
+ if (!st.isDirectory()) continue;
334
+ fs.rmSync(target, { recursive: true, force: true });
335
+ removed.push(target);
336
+ } catch {
337
+ // Best-effort — skip unreadable / racy entries.
338
+ }
339
+ }
340
+ }
341
+ } catch {
342
+ // Best-effort — never crash plugin init on cleanup failure.
343
+ }
344
+ return removed;
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Partial-install detection (rc.22 finding #5)
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Marker filename written into the plugin directory at register-time. Its
353
+ * presence means a prior install was interrupted before the plugin successfully
354
+ * loaded — a confirmed-broken half-state that the next `openclaw plugins
355
+ * install` retry can detect and clean.
356
+ *
357
+ * Conceptually the marker is dropped BEFORE npm install completes (the
358
+ * complementary npm script removes it on success) and additionally
359
+ * re-asserted at register-time as a second-line check. If you see this file
360
+ * in `<extensionsDir>/totalreclaw/`, the install never reached register()
361
+ * AND the marker drop wasn't undone.
362
+ *
363
+ * Constants are exported so the npm preinstall/cleanup scripts in
364
+ * `package.json` use the same name as the runtime detector.
365
+ */
366
+ export const PARTIAL_INSTALL_MARKER = '.tr-partial-install';
367
+
368
+ /** Package name we own — used to confirm a directory is OUR plugin, not a stray. */
369
+ export const PLUGIN_PACKAGE_NAME = '@totalreclaw/totalreclaw';
370
+
371
+ /**
372
+ * Outcome of `detectPartialInstall`.
373
+ * - `'clean'` — the dir is a fully-installed plugin (package.json claims our
374
+ * name AND `dist/index.js` exists AND no marker present).
375
+ * - `'partial'` — the dir is OUR plugin but in a corrupt half-state. Caller
376
+ * should wipe + retry. Returned reasons include:
377
+ * * marker file present (preinstall fired, postinstall did not)
378
+ * * dist/index.js missing (build never finished)
379
+ * - `'foreign'` — package.json missing or claims a different name. Helper
380
+ * refuses to act so we never delete an unrelated plugin.
381
+ * - `'absent'` — dir does not exist at all.
382
+ */
383
+ export type PartialInstallStatus = 'clean' | 'partial' | 'foreign' | 'absent';
384
+
385
+ export interface PartialInstallResult {
386
+ status: PartialInstallStatus;
387
+ /** Why the caller decided this is partial — surfaces in error messages. */
388
+ reasons: string[];
389
+ }
390
+
391
+ /**
392
+ * Inspect a plugin install directory to decide whether it is fully installed,
393
+ * a corrupted half-state from an interrupted install, or someone else's
394
+ * plugin. Pure filesystem inspection; never deletes anything.
395
+ *
396
+ * Background — rc.22 finding #5
397
+ * ------------------------------
398
+ * After a partial `openclaw plugins install @totalreclaw/totalreclaw` (e.g.
399
+ * the auto-gateway-restart kills npm mid-build), `extensions/totalreclaw/`
400
+ * survives with a populated package.json but a missing or empty `dist/`. The
401
+ * agent's recovery retry then fires another install; OpenClaw's plugin
402
+ * loader scans `extensions/` and tries to register the half-state as a "hook
403
+ * pack", failing with the cryptic "package.json missing openclaw.hooks". The
404
+ * fix: detect the partial state up-front so the retry can wipe + reinstall
405
+ * instead of cargo-culting a confused error.
406
+ *
407
+ * Decision rules
408
+ * --------------
409
+ * 1. `pluginRootDir` does not exist → `'absent'`.
410
+ * 2. package.json missing or unparsable → `'foreign'` (don't touch).
411
+ * 3. package.json `name !== '@totalreclaw/totalreclaw'` → `'foreign'`.
412
+ * 4. `<root>/.tr-partial-install` exists → `'partial'` (the canonical signal).
413
+ * 5. `<root>/dist/index.js` missing → `'partial'` (build never finished).
414
+ * 6. otherwise → `'clean'`.
415
+ *
416
+ * The function is intentionally conservative: it returns `'foreign'` on any
417
+ * ambiguous read. Callers should NEVER auto-wipe a `'foreign'` directory.
418
+ *
419
+ * @param pluginRootDir Absolute path to the suspect plugin dir, e.g.
420
+ * `~/.openclaw/extensions/totalreclaw`.
421
+ */
422
+ export function detectPartialInstall(pluginRootDir: string): PartialInstallResult {
423
+ const reasons: string[] = [];
424
+
425
+ // Rule 1 — absent dir.
426
+ let rootStat: fs.Stats;
427
+ try {
428
+ rootStat = fs.statSync(pluginRootDir);
429
+ } catch {
430
+ return { status: 'absent', reasons: ['directory does not exist'] };
431
+ }
432
+ if (!rootStat.isDirectory()) {
433
+ return { status: 'foreign', reasons: ['path exists but is not a directory'] };
434
+ }
435
+
436
+ // Rules 2-3 — package.json must claim our name.
437
+ const pkgJsonPath = path.join(pluginRootDir, 'package.json');
438
+ let pkgRaw: string;
439
+ try {
440
+ pkgRaw = fs.readFileSync(pkgJsonPath, 'utf-8');
441
+ } catch {
442
+ return { status: 'foreign', reasons: ['package.json missing or unreadable'] };
443
+ }
444
+ let parsed: { name?: unknown };
445
+ try {
446
+ parsed = JSON.parse(pkgRaw) as { name?: unknown };
447
+ } catch {
448
+ return { status: 'foreign', reasons: ['package.json is not valid JSON'] };
449
+ }
450
+ if (parsed.name !== PLUGIN_PACKAGE_NAME) {
451
+ return {
452
+ status: 'foreign',
453
+ reasons: [`package.json declares "${String(parsed.name)}" not "${PLUGIN_PACKAGE_NAME}"`],
454
+ };
455
+ }
456
+
457
+ // Rule 4 — explicit partial marker wins.
458
+ const markerPath = path.join(pluginRootDir, PARTIAL_INSTALL_MARKER);
459
+ if (fs.existsSync(markerPath)) {
460
+ reasons.push(`${PARTIAL_INSTALL_MARKER} marker present (preinstall fired, postinstall did not)`);
461
+ }
462
+
463
+ // Rule 5 — dist/index.js must exist for the loader to register.
464
+ const distIndex = path.join(pluginRootDir, 'dist', 'index.js');
465
+ if (!fs.existsSync(distIndex)) {
466
+ reasons.push('dist/index.js missing (build artifact absent)');
467
+ }
468
+
469
+ if (reasons.length > 0) {
470
+ return { status: 'partial', reasons };
471
+ }
472
+ return { status: 'clean', reasons: [] };
473
+ }
474
+
475
+ /**
476
+ * Wipe a partial-install directory so the next `openclaw plugins install`
477
+ * starts from a blank slate. Only acts when `detectPartialInstall(...)`
478
+ * returns `'partial'` — `'foreign'` and `'clean'` are no-ops by design.
479
+ *
480
+ * Returns `true` if the directory was wiped, `false` otherwise.
481
+ *
482
+ * SAFETY: this helper is the only place that recursively deletes a plugin
483
+ * dir. It refuses to act on `'foreign'` and `'clean'` results so a
484
+ * misconfigured caller can never wipe a healthy install or someone else's
485
+ * plugin.
486
+ */
487
+ export function wipePartialInstall(pluginRootDir: string): boolean {
488
+ const detection = detectPartialInstall(pluginRootDir);
489
+ if (detection.status !== 'partial') return false;
490
+ try {
491
+ fs.rmSync(pluginRootDir, { recursive: true, force: true });
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Drop the `.tr-partial-install` marker into `pluginRootDir`. Idempotent
500
+ * (overwrites any existing marker) and best-effort — returns `true` on
501
+ * success, `false` if the dir doesn't exist or write fails. Used by the
502
+ * `preinstall` npm script and (defensively) by the runtime if the npm
503
+ * preinstall/cleanup script pair did not fire.
504
+ */
505
+ export function writePartialInstallMarker(pluginRootDir: string): boolean {
506
+ try {
507
+ if (!fs.existsSync(pluginRootDir)) return false;
508
+ fs.writeFileSync(path.join(pluginRootDir, PARTIAL_INSTALL_MARKER), '');
509
+ return true;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Remove the partial-install marker. Called by the `postinstall` script and
517
+ * (defensively) at register-time once we've confirmed the load succeeded.
518
+ * Returns `true` if a marker was removed, `false` if there was nothing to
519
+ * remove.
520
+ */
521
+ export function clearPartialInstallMarker(pluginRootDir: string): boolean {
522
+ try {
523
+ const markerPath = path.join(pluginRootDir, PARTIAL_INSTALL_MARKER);
524
+ if (!fs.existsSync(markerPath)) return false;
525
+ fs.unlinkSync(markerPath);
526
+ return true;
527
+ } catch {
528
+ return false;
529
+ }
530
+ }
531
+
255
532
  // ---------------------------------------------------------------------------
256
533
  // Auto-bootstrap of credentials.json (3.1.0 first-run UX)
257
534
  // ---------------------------------------------------------------------------
package/gateway-url.ts CHANGED
@@ -126,27 +126,66 @@ function shouldSkipIface(name: string): boolean {
126
126
  return SKIP_IFACE_PREFIXES.some((p) => lower.startsWith(p));
127
127
  }
128
128
 
129
+ /**
130
+ * Docker container internal IP detection — issue #110 fix 4.
131
+ *
132
+ * From INSIDE a Docker container, `eth0` carries the container's bridge IP
133
+ * (e.g. `172.18.0.2`). That IP is reachable from other containers on the
134
+ * SAME Docker network but NOT from the host browser, the user's phone, or
135
+ * any external device. Surfacing it as the pairing URL produces a hard-
136
+ * dead user experience: "scan QR" yields connection-refused.
137
+ *
138
+ * Docker default-bridge ranges:
139
+ * - 172.17.0.0/16 — `bridge` (default)
140
+ * - 172.18.0.0/16 .. 172.31.0.0/16 — user-defined networks
141
+ *
142
+ * We use the conservative test: 172.16.0.0/12 (the full RFC-1918 172.x
143
+ * range, which is what Docker draws from). If the host is clearly Docker
144
+ * (`/.dockerenv`), we treat 172.16-31.x.x AS Docker-internal and skip it.
145
+ *
146
+ * Outside Docker, 172.16.x.x can be a legitimate corporate LAN, so we
147
+ * only apply the rule when we have positive Docker evidence.
148
+ */
149
+ export function isDockerInternalIp(addr: string): boolean {
150
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(addr)) return false;
151
+ const parts = addr.split('.').map((p) => Number.parseInt(p, 10));
152
+ if (parts[0] !== 172) return false;
153
+ return parts[1] >= 16 && parts[1] <= 31;
154
+ }
155
+
129
156
  /**
130
157
  * Pick the first non-loopback, non-virtual IPv4 address. Returns null if
131
158
  * none found (headless VPS with only lo + tailscale, for example).
159
+ *
160
+ * issue #110 fix 4: when the host is detected as Docker (caller passes
161
+ * `isDocker: true`), skip Docker-bridge IPs in the 172.16/12 range — they
162
+ * are container-internal and useless for any external browser. Returning
163
+ * null from this function in that scenario lets `buildPairingUrl` fall
164
+ * through to the localhost-with-relay-fallback warning rather than handing
165
+ * the user a dead URL.
132
166
  */
133
167
  export function detectLanHost(options?: {
134
168
  /** Override os.networkInterfaces for tests. */
135
169
  networkInterfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>;
170
+ /** True when the host is Docker — skips 172.16/12 bridge IPs. */
171
+ isDocker?: boolean;
136
172
  }): DetectedGatewayHost | null {
137
173
  const nif = (options?.networkInterfaces ?? os.networkInterfaces)();
138
174
  for (const [name, addrs] of Object.entries(nif)) {
139
175
  if (shouldSkipIface(name)) continue;
140
176
  if (!addrs) continue;
141
177
  for (const a of addrs) {
142
- if (a.family === 'IPv4' && !a.internal) {
143
- return {
144
- kind: 'lan',
145
- host: a.address,
146
- tls: false,
147
- note: `LAN IPv4 on interface ${name} — only reachable from the same network.`,
148
- };
149
- }
178
+ if (a.family !== 'IPv4' || a.internal) continue;
179
+ // issue #110 fix 4 — Docker container internal IP is unreachable
180
+ // from any external browser. Skip it so the caller falls back to
181
+ // the relay-brokered URL.
182
+ if (options?.isDocker && isDockerInternalIp(a.address)) continue;
183
+ return {
184
+ kind: 'lan',
185
+ host: a.address,
186
+ tls: false,
187
+ note: `LAN IPv4 on interface ${name} — only reachable from the same network.`,
188
+ };
150
189
  }
151
190
  }
152
191
  return null;
@@ -162,13 +201,22 @@ export function detectLanHost(options?: {
162
201
  *
163
202
  * Sync: no I/O, no subprocess, no network. Safe in sync callers like
164
203
  * `buildPairingUrl` in index.ts.
204
+ *
205
+ * issue #110 fix 4: the `isDocker` option, when true, skips the 172.16/12
206
+ * Docker-bridge range during LAN detection. The caller (index.ts) passes
207
+ * `isRunningInDocker()` so we don't surface a container-internal IP that
208
+ * no external browser can reach.
165
209
  */
166
210
  export function detectGatewayHost(options?: {
167
211
  networkInterfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>;
212
+ isDocker?: boolean;
168
213
  }): DetectedGatewayHost | null {
169
214
  const ts = detectTailscaleHost({ networkInterfaces: options?.networkInterfaces });
170
215
  if (ts) return ts;
171
- const lan = detectLanHost({ networkInterfaces: options?.networkInterfaces });
216
+ const lan = detectLanHost({
217
+ networkInterfaces: options?.networkInterfaces,
218
+ isDocker: options?.isDocker,
219
+ });
172
220
  if (lan) return lan;
173
221
  return null;
174
222
  }