@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
package/llm-client.ts CHANGED
@@ -72,8 +72,48 @@ const PROVIDER_KEY_NAMES: Record<string, string[]> = {
72
72
  cerebras: ['cerebras'],
73
73
  };
74
74
 
75
+ /**
76
+ * zai has TWO public endpoints. The CODING endpoint is what GLM Coding Plan
77
+ * subscription keys are provisioned against; the STANDARD (PAYG) endpoint
78
+ * serves pay-as-you-go balances. A coding-plan key that hits the STANDARD
79
+ * endpoint returns HTTP 429 with body `"Insufficient balance or no resource
80
+ * package. Please recharge."` — misleading because the subscription is in
81
+ * good standing. Vice-versa for PAYG keys that accidentally hit CODING.
82
+ *
83
+ * 3.3.1-rc.3: exported so the rc.3 auto-fallback (see `chatCompletion`)
84
+ * can flip between them when the upstream error signature matches.
85
+ */
86
+ export const ZAI_CODING_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
87
+ export const ZAI_STANDARD_BASE_URL = 'https://api.z.ai/api/paas/v4';
88
+
89
+ /**
90
+ * Resolve the zai base URL.
91
+ *
92
+ * Precedence:
93
+ * 1. `ZAI_BASE_URL` env var (explicit operator override — read by
94
+ * `CONFIG.zaiBaseUrl` via a getter so tests can mutate the env
95
+ * between calls)
96
+ * 2. Default: coding endpoint (coding-plan-biased; the rc.3 auto-fallback
97
+ * hops to the standard endpoint on an "Insufficient balance" 429).
98
+ *
99
+ * Documented in plugin SKILL.md — Coding-Plan users can leave it unset (or
100
+ * set it explicitly to `https://api.z.ai/api/coding/paas/v4`). PAYG users
101
+ * MUST set it to `https://api.z.ai/api/paas/v4` to avoid the auto-fallback
102
+ * tax on every first call.
103
+ *
104
+ * Scanner-isolation note: the env read lives in `config.ts` (which has no
105
+ * network triggers). This module has network calls, so it cannot touch
106
+ * env vars directly — both rules 1 (env-harvesting) and 2 (potential-
107
+ * exfiltration) in check-scanner.mjs would fire.
108
+ */
109
+ export function getZaiBaseUrl(): string {
110
+ return CONFIG.zaiBaseUrl;
111
+ }
112
+
75
113
  const PROVIDER_BASE_URLS: Record<string, string> = {
76
- zai: 'https://api.z.ai/api/coding/paas/v4',
114
+ // zai: resolved lazily at each init/call so `ZAI_BASE_URL` env changes
115
+ // propagate without a module re-import. See `getZaiBaseUrl()`.
116
+ zai: getZaiBaseUrl(),
77
117
  anthropic: 'https://api.anthropic.com/v1',
78
118
  openai: 'https://api.openai.com/v1',
79
119
  gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
@@ -196,7 +236,13 @@ function buildConfigForProvider(
196
236
  apiFormatOverride?: 'openai' | 'anthropic';
197
237
  } = {},
198
238
  ): LLMClientConfig | null {
199
- const baseUrl = (opts.baseUrlOverride ?? PROVIDER_BASE_URLS[provider] ?? '').replace(/\/+$/, '');
239
+ // zai's base URL is resolved via `getZaiBaseUrl()` (reads CONFIG) so
240
+ // the `ZAI_BASE_URL` env override takes effect even when this helper is
241
+ // called with no `baseUrlOverride` (i.e. the env-var fallback tier in
242
+ // initLLMClient).
243
+ const defaultForProvider =
244
+ provider === 'zai' ? getZaiBaseUrl() : PROVIDER_BASE_URLS[provider] ?? '';
245
+ const baseUrl = (opts.baseUrlOverride ?? defaultForProvider).replace(/\/+$/, '');
200
246
  if (!baseUrl) return null;
201
247
  const model =
202
248
  opts.modelOverride ??
@@ -466,7 +512,7 @@ export function resolveLLMConfig(): LLMClientConfig | null {
466
512
  if (zaiKey) {
467
513
  return {
468
514
  apiKey: zaiKey,
469
- baseUrl: 'https://api.z.ai/api/coding/paas/v4',
515
+ baseUrl: getZaiBaseUrl(),
470
516
  model,
471
517
  apiFormat: 'openai',
472
518
  };
@@ -486,22 +532,29 @@ export function resolveLLMConfig(): LLMClientConfig | null {
486
532
 
487
533
  /**
488
534
  * Options for chatCompletion. `retry` controls the 429 + timeout backoff
489
- * loop added in 3.3.1-rc.2 5 of 6 extraction windows failed in the
490
- * 3.3.1-rc.1 QA because zai 429s had no retry path.
535
+ * loop. Defaults to 5 attempts with 2s 4s 8s 16s → 32s backoff
536
+ * (total budget ~62s) — rc.1/rc.2 QA showed multi-minute upstream outages
537
+ * that blew through the rc.2 7s budget. Configurable via
538
+ * `TOTALRECLAW_LLM_RETRY_BUDGET_MS` env (cap on cumulative retry-delay).
491
539
  */
492
540
  export interface ChatCompletionOptions {
493
541
  maxTokens?: number;
494
542
  temperature?: number;
495
543
  /**
496
- * Retry behaviour. Defaults to { attempts: 3, baseDelayMs: 1000 }
497
- * 1s 2s 4s exponential backoff on 429 or transient timeout. First
498
- * failure logs at INFO (single-line, no stack), subsequent attempts at
499
- * DEBUG. Set `attempts: 0` to disable retry entirely. Pass a `logger`
500
- * for visibility; without one, retries are silent.
544
+ * Retry behaviour. Defaults mirror the rc.3 budget: 5 attempts, 2s base
545
+ * delay, exponential. Set `attempts: 0` (or `1`) to disable retry. Pass
546
+ * a `logger` for visibility; without one, retries are silent.
547
+ *
548
+ * `budgetMs` caps the cumulative retry-delay time — after an attempt
549
+ * fails, we compute the next delay and skip it (falling through to the
550
+ * give-up path) if adding it would exceed the budget. Defaults to the
551
+ * value read from `TOTALRECLAW_LLM_RETRY_BUDGET_MS` at module load,
552
+ * which itself defaults to 60_000ms.
501
553
  */
502
554
  retry?: {
503
555
  attempts?: number;
504
556
  baseDelayMs?: number;
557
+ budgetMs?: number;
505
558
  };
506
559
  logger?: {
507
560
  info?: (msg: string) => void;
@@ -512,17 +565,76 @@ export interface ChatCompletionOptions {
512
565
  timeoutMs?: number;
513
566
  }
514
567
 
568
+ /**
569
+ * Default retry budget in ms. Configurable via
570
+ * `TOTALRECLAW_LLM_RETRY_BUDGET_MS` env var — read by `config.ts`. Callers
571
+ * can override per-call via `retry.budgetMs`. 60_000ms covers ~8 minutes
572
+ * worth of upstream outages with the 2s→32s schedule.
573
+ *
574
+ * Scanner-isolation note: the env read lives in `config.ts` so this file
575
+ * stays clean of env-harvesting triggers.
576
+ */
577
+ export const DEFAULT_RETRY_BUDGET_MS: number = CONFIG.llmRetryBudgetMs;
578
+
579
+ /**
580
+ * Structured error thrown when the extraction LLM upstream is unreachable
581
+ * after the full retry budget is exhausted. The extraction pipeline
582
+ * recognizes this via `err instanceof LLMUpstreamOutageError` and can
583
+ * choose to:
584
+ * - queue the message batch for retry next turn,
585
+ * - surface a one-time notification to the user, or
586
+ * - simply skip this extraction window silently.
587
+ */
588
+ export class LLMUpstreamOutageError extends Error {
589
+ readonly attempts: number;
590
+ readonly lastStatus?: number;
591
+ constructor(message: string, attempts: number, lastStatus?: number) {
592
+ super(message);
593
+ this.name = 'LLMUpstreamOutageError';
594
+ this.attempts = attempts;
595
+ this.lastStatus = lastStatus;
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Detect the "Insufficient balance" error shape from zai. Matches both
601
+ * the exact production wording ("Insufficient balance or no resource
602
+ * package. Please recharge.") and the short "no resource package" variant
603
+ * we've seen in some historical responses.
604
+ */
605
+ export function isZaiBalanceError(errorMessage: string): boolean {
606
+ const m = errorMessage.toLowerCase();
607
+ return m.includes('insufficient balance') || m.includes('no resource package');
608
+ }
609
+
610
+ /**
611
+ * Identify the "other" zai endpoint when the current one returns a balance
612
+ * error — CODING ↔ STANDARD. Returns `null` when the URL is neither of
613
+ * the two zai endpoints we know about (e.g. a self-hosted proxy), which
614
+ * means the fallback logic stays put.
615
+ */
616
+ export function zaiFallbackBaseUrl(currentBaseUrl: string): string | null {
617
+ const normalized = currentBaseUrl.replace(/\/+$/, '');
618
+ if (normalized === ZAI_CODING_BASE_URL) return ZAI_STANDARD_BASE_URL;
619
+ if (normalized === ZAI_STANDARD_BASE_URL) return ZAI_CODING_BASE_URL;
620
+ return null;
621
+ }
622
+
515
623
  /**
516
624
  * Call the LLM chat completion endpoint.
517
625
  *
518
626
  * Supports both OpenAI-compatible format and Anthropic Messages API,
519
627
  * determined by `config.apiFormat`.
520
628
  *
521
- * 3.3.1-rc.2adds an exponential-backoff retry wrapper for HTTP 429 +
522
- * timeout transients. Every retry attempt respects the per-attempt
523
- * `timeoutMs` (default 30s). Max 3 total attempts by default (1s, 2s, 4s
524
- * backoff). Non-retryable errors (4xx other than 429, network refused,
525
- * JSON parse) fail fast on the first attempt.
629
+ * 3.3.1-rc.3lifts the retry budget 5 attempts × (2s/4s/8s/16s/32s), total
630
+ * ~62s. Configurable via `TOTALRECLAW_LLM_RETRY_BUDGET_MS`. Adds zai
631
+ * "Insufficient balance" auto-fallback: when a zai 429 carries the balance
632
+ * error body AND we're on one of the two known zai endpoints, we flip to
633
+ * the OTHER endpoint and retry ONCE (accounted for separately from the
634
+ * normal retry loop). On exhaustion, throws `LLMUpstreamOutageError`.
635
+ *
636
+ * Non-retryable errors (4xx other than 429, network refused, JSON parse)
637
+ * fail fast on the first attempt.
526
638
  *
527
639
  * @returns The assistant's response content, or null on failure.
528
640
  */
@@ -533,34 +645,96 @@ export async function chatCompletion(
533
645
  ): Promise<string | null> {
534
646
  const maxTokens = options?.maxTokens ?? 2048;
535
647
  const temperature = options?.temperature ?? 0; // Deterministic output for dedup (same input → same text → same content fingerprint)
536
- const attempts = Math.max(1, options?.retry?.attempts ?? 3);
537
- const baseDelayMs = Math.max(100, options?.retry?.baseDelayMs ?? 1000);
648
+ const attempts = Math.max(1, options?.retry?.attempts ?? 5);
649
+ const baseDelayMs = Math.max(100, options?.retry?.baseDelayMs ?? 2000);
650
+ const budgetMs = Math.max(100, options?.retry?.budgetMs ?? DEFAULT_RETRY_BUDGET_MS);
538
651
  const timeoutMs = options?.timeoutMs ?? 30_000;
539
652
  const logger = options?.logger;
540
653
 
654
+ // We mutate `activeConfig.baseUrl` in the zai fallback branch so the
655
+ // retried call hits the other endpoint. Shallow-clone so the caller's
656
+ // config object stays untouched.
657
+ const activeConfig: LLMClientConfig = { ...config };
658
+
659
+ // One-shot flag: we only auto-fallback zai once per chatCompletion call
660
+ // to prevent ping-pong between the two endpoints if both reject.
661
+ let zaiFallbackAttempted = false;
662
+
541
663
  const callOnce = (): Promise<string | null> =>
542
- config.apiFormat === 'anthropic'
543
- ? chatCompletionAnthropic(config, messages, maxTokens, temperature, timeoutMs)
544
- : chatCompletionOpenAI(config, messages, maxTokens, temperature, timeoutMs);
664
+ activeConfig.apiFormat === 'anthropic'
665
+ ? chatCompletionAnthropic(activeConfig, messages, maxTokens, temperature, timeoutMs)
666
+ : chatCompletionOpenAI(activeConfig, messages, maxTokens, temperature, timeoutMs);
545
667
 
546
668
  let lastErr: unknown;
669
+ let cumulativeDelayMs = 0;
670
+ let lastStatus: number | undefined;
671
+
547
672
  for (let attempt = 1; attempt <= attempts; attempt++) {
548
673
  try {
549
674
  return await callOnce();
550
675
  } catch (err) {
551
676
  lastErr = err;
552
677
  const msg = err instanceof Error ? err.message : String(err);
678
+ lastStatus = parseHttpStatus(msg) ?? lastStatus;
679
+
680
+ // ── zai "Insufficient balance" auto-fallback ──
681
+ // Fires BEFORE the normal retry accounting. If the error is a zai
682
+ // balance-shaped 429, flip the baseUrl once and immediately retry —
683
+ // no backoff, no decrement of the attempt count. Keeps the total
684
+ // attempt budget reserved for genuine outages.
685
+ if (!zaiFallbackAttempted && /\b429\b/.test(msg) && isZaiBalanceError(msg)) {
686
+ const fallback = zaiFallbackBaseUrl(activeConfig.baseUrl);
687
+ if (fallback) {
688
+ zaiFallbackAttempted = true;
689
+ const oldUrl = activeConfig.baseUrl;
690
+ activeConfig.baseUrl = fallback;
691
+ logger?.info?.(
692
+ `chatCompletion: zai endpoint auto-fallback: ${oldUrl} → ${fallback} due to "Insufficient balance" response`,
693
+ );
694
+ // Retry immediately — do NOT decrement attempts counter further;
695
+ // this "extra" attempt is the fallback freebie.
696
+ attempt--;
697
+ continue;
698
+ }
699
+ }
700
+
553
701
  const retryable = isRetryable(msg);
554
702
  const isFinalAttempt = attempt >= attempts;
555
703
  if (!retryable || isFinalAttempt) {
556
704
  // Fail-fast OR last attempt — rethrow.
557
- if (attempt > 1) {
558
- logger?.warn?.(`chatCompletion: giving up after ${attempt} attempts: ${msg.slice(0, 200)}`);
705
+ if (attempt > 1 || !retryable) {
706
+ if (retryable) {
707
+ logger?.warn?.(`chatCompletion: giving up after ${attempt} attempts: ${msg.slice(0, 200)}`);
708
+ }
709
+ // Structured outage error when the retryable error budget is
710
+ // fully exhausted — lets downstream recognize vs bail silently.
711
+ if (retryable) {
712
+ throw new LLMUpstreamOutageError(
713
+ `LLM upstream outage after ${attempt} attempts: ${msg.slice(0, 200)}`,
714
+ attempt,
715
+ lastStatus,
716
+ );
717
+ }
559
718
  }
560
719
  throw err;
561
720
  }
562
- // Retry. INFO on first failure (visible), DEBUG on subsequent.
721
+
722
+ // Compute next delay, but respect the cumulative retry-budget cap.
563
723
  const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
724
+ if (cumulativeDelayMs + delayMs > budgetMs) {
725
+ logger?.warn?.(
726
+ `chatCompletion: retry budget exhausted (${cumulativeDelayMs}ms used + ${delayMs}ms next > ${budgetMs}ms budget); surfacing outage after ${attempt} attempts: ${msg.slice(0, 160)}`,
727
+ );
728
+ throw new LLMUpstreamOutageError(
729
+ `LLM upstream outage (budget ${budgetMs}ms exhausted after ${attempt} attempts): ${msg.slice(0, 200)}`,
730
+ attempt,
731
+ lastStatus,
732
+ );
733
+ }
734
+ cumulativeDelayMs += delayMs;
735
+
736
+ // Log only the FIRST retry at INFO to avoid spamming during long
737
+ // outages; subsequent retries are DEBUG (debounced per outage).
564
738
  if (attempt === 1) {
565
739
  logger?.info?.(
566
740
  `chatCompletion: retrying after transient failure (attempt ${attempt}/${attempts}, wait ${delayMs}ms): ${msg.slice(0, 160)}`,
@@ -578,6 +752,20 @@ export async function chatCompletion(
578
752
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
579
753
  }
580
754
 
755
+ /**
756
+ * Parse the HTTP status code from an error message of the form
757
+ * `"LLM API 429: rate limit"` or `"Anthropic API 503: ..."`. Returns
758
+ * `undefined` when the message doesn't follow that shape (e.g. network
759
+ * refused). Used by `LLMUpstreamOutageError.lastStatus` for downstream
760
+ * classification.
761
+ */
762
+ function parseHttpStatus(errorMessage: string): number | undefined {
763
+ const m = errorMessage.match(/\b(\d{3})\b/);
764
+ if (!m) return undefined;
765
+ const code = parseInt(m[1], 10);
766
+ return code >= 100 && code < 600 ? code : undefined;
767
+ }
768
+
581
769
  /**
582
770
  * Which LLM-call errors are worth retrying. Exported for testability.
583
771
  *
package/lsh.ts CHANGED
@@ -7,10 +7,15 @@
7
7
  * Default parameters: 32 bits per table, 20 tables.
8
8
  */
9
9
 
10
- // Lazy-load WASM to avoid crash when npm install hasn't finished yet.
10
+ // Lazy-load WASM via createRequire. The shipped `dist/index.js` is ESM-only
11
+ // (`"type":"module"`) so the bare `require` global is undefined at runtime.
12
+ // See issue #124 for the bug this avoids; matches the pattern in
13
+ // claims-helper / consolidation / digest-sync / pin / retype-setscope.
14
+ import { createRequire } from 'node:module';
15
+ const requireWasm = createRequire(import.meta.url);
11
16
  let _WasmLshHasher: typeof import('@totalreclaw/core')['WasmLshHasher'] | null = null;
12
17
  function getWasmLshHasher() {
13
- if (!_WasmLshHasher) _WasmLshHasher = require('@totalreclaw/core').WasmLshHasher;
18
+ if (!_WasmLshHasher) _WasmLshHasher = requireWasm('@totalreclaw/core').WasmLshHasher;
14
19
  return _WasmLshHasher!;
15
20
  }
16
21
 
package/onboarding-cli.ts CHANGED
@@ -59,6 +59,23 @@ import {
59
59
  // has one place to grow into later).
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
+ /**
63
+ * 3.3.1-rc.18 (issue #95) — deprecation warning for the interactive
64
+ * phrase-print branch. Emitted to STDERR (never stdout) so it is visible
65
+ * to humans but does not pollute any pipe consuming the wizard's output.
66
+ *
67
+ * The phrase-print branch will be REMOVED in the next RC after rc.18.
68
+ * Users running on a TTY can still complete the flow in rc.18; agents
69
+ * MUST use `--pair-only` or the `totalreclaw_pair` tool today.
70
+ */
71
+ export const PHRASE_PRINT_DEPRECATION_WARNING =
72
+ '\nDEPRECATION (issue #95): the interactive `openclaw totalreclaw onboard` flow\n' +
73
+ ' prints your recovery phrase to this terminal. This is being removed in the\n' +
74
+ ' next release candidate. For agent / scripted invocation, use:\n' +
75
+ ' openclaw totalreclaw onboard --pair-only\n' +
76
+ ' which emits ONLY {pair_url, pin} JSON and routes the phrase through the\n' +
77
+ ' browser flow (never on stdout).\n\n';
78
+
62
79
  export const COPY = {
63
80
  welcome:
64
81
  '\nTotalReclaw — Secure onboarding\n\n' +
@@ -403,6 +420,11 @@ export async function runOnboardingWizard(deps: WizardDeps): Promise<WizardResul
403
420
  }
404
421
 
405
422
  if (choice === 'generate') {
423
+ // 3.3.1-rc.18 (issue #95) — deprecation banner on stderr ONLY.
424
+ // The phrase-print branch is scheduled for removal in the RC after
425
+ // rc.18; we keep it functional in rc.18 for back-compat with users
426
+ // running the wizard on a real TTY today.
427
+ io.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
406
428
  io.stdout.write(COPY.generateWarning);
407
429
  io.stdout.write(COPY.importRemoteLimitation);
408
430
  const mnemonic = genMnemonic();
@@ -674,6 +696,22 @@ export async function runNonInteractiveOnboard(
674
696
  * --emit-phrase Include the plaintext phrase in the JSON payload
675
697
  * (NOT recommended — the phrase lives in
676
698
  * credentials.json; prefer reading it there).
699
+ *
700
+ * 3.3.1-rc.18 — `onboard` accepts:
701
+ * --pair-only Phrase-safe agent-shell flag (issue #95).
702
+ * Delegates to the pair flow and emits a single
703
+ * line of JSON `{v, pair_url, pin, expires_at_ms}`
704
+ * to stdout. Phrase NEVER touches stdout, stderr,
705
+ * or the logger in this mode. Use this for any
706
+ * agent-driven setup; it is the recommended path
707
+ * when a container-based agent does not have the
708
+ * `totalreclaw_pair` tool injected.
709
+ *
710
+ * Requires `pairSessionsPath` + `renderPairingUrl`
711
+ * to be supplied to `registerOnboardingCli`. If
712
+ * absent, `--pair-only` exits non-zero with a
713
+ * clear message instead of falling through to the
714
+ * phrase-print branch.
677
715
  */
678
716
  export function registerOnboardingCli(
679
717
  program: import('commander').Command,
@@ -683,6 +721,10 @@ export function registerOnboardingCli(
683
721
  logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
684
722
  /** Caller-supplied helper for scope-address derivation. Optional — when absent, JSON output omits `scope_address`. */
685
723
  deriveScopeAddress?: (mnemonic: string) => Promise<string | undefined>;
724
+ /** Caller-supplied path to the pair-session store. Required for `--pair-only`. */
725
+ pairSessionsPath?: string;
726
+ /** Caller-supplied URL renderer for the pair flow. Required for `--pair-only`. */
727
+ renderPairingUrl?: (session: import('./pair-session-store.js').PairSession) => string;
686
728
  },
687
729
  ): void {
688
730
  const tr = program
@@ -690,12 +732,13 @@ export function registerOnboardingCli(
690
732
  .description('TotalReclaw encrypted memory — secure onboarding + status');
691
733
 
692
734
  tr.command('onboard')
693
- .description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM)')
735
+ .description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM). For agent-driven setup prefer --pair-only.')
694
736
  .option('--non-interactive', 'Exit non-zero if any input would be prompted for (agent-driven use)')
695
737
  .option('--json', 'Emit the result as a structured JSON payload. Only valid with --non-interactive.')
696
738
  .option('--mode <mode>', 'generate | restore — skip the menu prompt')
697
739
  .option('--phrase <phrase>', 'Recovery phrase for --mode restore. `-` reads from stdin.')
698
740
  .option('--emit-phrase', 'Include the plaintext phrase in the JSON payload (not recommended). Default: false.')
741
+ .option('--pair-only', 'Phrase-safe agent-invocation mode (issue #95). Emits ONLY {v,pair_url,pin,expires_at_ms} JSON to stdout via the pair flow. Phrase never touches stdout/stderr/logger. RECOMMENDED for any agent or scripted invocation.')
699
742
  .action(async (...actionArgs: unknown[]) => {
700
743
  // commander: (options, cmd)
701
744
  const cliOpts = (actionArgs[0] ?? {}) as {
@@ -704,8 +747,70 @@ export function registerOnboardingCli(
704
747
  mode?: string;
705
748
  phrase?: string;
706
749
  emitPhrase?: boolean;
750
+ pairOnly?: boolean;
707
751
  };
708
752
 
753
+ // ---------------------------------------------------------------
754
+ // 3.3.1-rc.18 — `--pair-only` (issue #95)
755
+ //
756
+ // Phrase-safe agent-shell flag. Delegates to the pair flow and
757
+ // emits a single line of JSON `{v, pair_url, pin, expires_at_ms}`
758
+ // to stdout. By construction:
759
+ // - The pair flow is x25519-only — pair-crypto.ts does NOT
760
+ // import @scure/bip39 and never touches a recovery phrase.
761
+ // - No interactive prompts, no readline, no @scure/bip39 import
762
+ // in this code path. Phrase never enters stdout/stderr/logger.
763
+ // - Stays silent on status transitions (the runPairCli
764
+ // `pair-only` output mode suppresses banners, spinners, and
765
+ // all human-readable copy).
766
+ //
767
+ // This MUST be the path agents take when they need to set up
768
+ // TotalReclaw via a shell. The interactive phrase-print branch
769
+ // below is deprecated for that use case and emits a warning when
770
+ // the user falls through to it.
771
+ // ---------------------------------------------------------------
772
+ if (cliOpts.pairOnly) {
773
+ if (!opts.pairSessionsPath || !opts.renderPairingUrl) {
774
+ process.stderr.write(
775
+ '--pair-only is unavailable: this OpenClaw build did not wire the pair flow into the onboard CLI. ' +
776
+ 'Use `openclaw totalreclaw pair generate --url-pin-only` instead.\n',
777
+ );
778
+ process.exit(1);
779
+ }
780
+ // Resolve mode. --mode restore is incompatible with --pair-only
781
+ // since pair flow's "import" mode runs in the browser, not in
782
+ // the CLI. Default to 'generate' silently.
783
+ const pairMode = cliOpts.mode === 'restore' || cliOpts.mode === 'import' ? 'import' : 'generate';
784
+
785
+ // Lazy import — keeps pair-cli + qrcode-terminal off the
786
+ // onboarding hot path when --pair-only is not used.
787
+ const { runPairCli, defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
788
+ const io = buildDefaultPairCliIo();
789
+ try {
790
+ const outcome = await runPairCli(pairMode, {
791
+ sessionsPath: opts.pairSessionsPath,
792
+ renderPairingUrl: opts.renderPairingUrl,
793
+ renderQr: defaultRenderQr,
794
+ io,
795
+ outputMode: 'pair-only',
796
+ });
797
+ if (outcome.status !== 'completed') {
798
+ process.exit(outcome.status === 'canceled' ? 130 : 1);
799
+ }
800
+ process.exit(0);
801
+ } catch (err) {
802
+ // CRITICAL: this catch MUST NOT include the phrase, the
803
+ // mnemonic, or any user secret in the message. The pair flow
804
+ // does not produce phrase material, so this is structurally
805
+ // safe — but defense-in-depth: emit a fixed error string.
806
+ opts.logger.error(
807
+ `pair-only delegation crashed: ${err instanceof Error ? err.message : String(err)}`,
808
+ );
809
+ process.stderr.write('--pair-only failed (see logs).\n');
810
+ process.exit(2);
811
+ }
812
+ }
813
+
709
814
  if (cliOpts.nonInteractive) {
710
815
  // Non-interactive path — no readline, no prompts.
711
816
  const mode: 'generate' | 'restore' | null =
@@ -744,10 +849,18 @@ export function registerOnboardingCli(
744
849
  });
745
850
 
746
851
  if (cliOpts.json) {
852
+ // 3.3.1-rc.18 (issue #95) — emit deprecation on stderr when
853
+ // the JSON payload is about to include the plaintext phrase.
854
+ // stderr is intentional: stdout must remain a single
855
+ // machine-parseable JSON line.
856
+ if (cliOpts.emitPhrase && result.ok && result.mnemonic) {
857
+ process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
858
+ }
747
859
  process.stdout.write(JSON.stringify(result) + '\n');
748
860
  } else {
749
861
  if (result.ok) {
750
862
  if (result.mnemonic) {
863
+ process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
751
864
  process.stderr.write(
752
865
  'WARNING: --emit-phrase was set. The plaintext recovery phrase was returned.\n' +
753
866
  'For agent-driven flows, prefer reading ~/.totalreclaw/credentials.json directly ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.1-rc.2",
3
+ "version": "3.3.1-rc.21",
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": [
@@ -31,13 +31,23 @@
31
31
  "author": "TotalReclaw Team",
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
+ "@huggingface/transformers": "^4.0.1",
34
35
  "@totalreclaw/client": "^1.2.0",
35
36
  "@totalreclaw/core": "^2.1.1",
36
- "@huggingface/transformers": "^4.0.1",
37
+ "@types/qrcode": "^1.5.6",
38
+ "@types/ws": "^8.5.12",
37
39
  "onnxruntime-node": "^1.24.0",
38
- "qrcode-terminal": "^0.12.0"
40
+ "qrcode": "^1.5.4",
41
+ "qrcode-terminal": "^0.12.0",
42
+ "ws": "^8.18.3"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.5.0"
39
46
  },
47
+ "main": "./dist/index.js",
48
+ "types": "./dist/index.d.ts",
40
49
  "files": [
50
+ "dist/",
41
51
  "*.ts",
42
52
  "import-adapters/",
43
53
  "!**/*.test.ts",
@@ -50,13 +60,17 @@
50
60
  "skill.json"
51
61
  ],
52
62
  "scripts": {
53
- "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.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",
63
+ "build": "rm -rf dist && tsc -p tsconfig.json --noCheck",
64
+ "verify-tarball": "node ../scripts/verify-tarball.mjs",
65
+ "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 recall-relevance-gate.test.ts && npx tsx install-staging-cleanup.test.ts && npx tsx json-stdout-cleanliness.test.ts",
66
+ "smoke:dist": "npx tsx dist-esm-smoke.test.ts",
54
67
  "check-scanner": "node ../scripts/check-scanner.mjs",
68
+ "prepack": "npm run build",
55
69
  "prepublishOnly": "node ../scripts/check-scanner.mjs"
56
70
  },
57
71
  "openclaw": {
58
72
  "extensions": [
59
- "./index.ts"
73
+ "./dist/index.js"
60
74
  ]
61
75
  }
62
76
  }