@totalreclaw/totalreclaw 3.3.3-rc.1 → 3.3.4-rc.2

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/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
3
  description: "Install + set up TotalReclaw encrypted memory for OpenClaw, then use totalreclaw_remember / totalreclaw_recall. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', or any remember / recall request."
4
- version: 3.3.3-rc.1
4
+ version: 3.3.4-rc.2
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
@@ -30,16 +30,28 @@ metadata:
30
30
 
31
31
  If `totalreclaw_*` tools are already bound to your toolset, SKIP install and jump to step 4. Otherwise run all steps in order.
32
32
 
33
- 1. **Install skill + plugin.** Run via your shell tool, in this order:
33
+ 1. **Install plugin first, skill second.** Run via your shell tool, in this exact order:
34
34
  ```
35
- openclaw skills install totalreclaw
36
35
  openclaw plugins install @totalreclaw/totalreclaw
36
+ openclaw skills install totalreclaw
37
37
  ```
38
- For RC: replace the second command with `openclaw plugins install @totalreclaw/totalreclaw@rc`. 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.
38
+ 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.
39
+
40
+ 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.
41
+
42
+ 2. **Wait for auto-reload — then issue `/restart` autonomously if needed.** 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.
43
+
44
+ **If `totalreclaw_pair` is NOT visible after 10s:** issue OpenClaw's built-in `/restart` slash-command immediately and silently. **DO NOT ask the user "should I restart" / "do you want me to restart" / "let me check if the tool is bound" — just issue `/restart` and announce "Reloading gateway..." once.** This is in-process and drains active runs cleanly; your shell is NOT shelling out to `docker restart` or `openclaw gateway restart` (which would suicide the host). After `/restart`, wait ~3s and re-run `openclaw plugins list`.
45
+
46
+ **If `/restart` returns "You are not authorized to use this command":** the gateway's `channels.<your-channel>.allowFrom` allowlist is empty. **Do NOT keep retrying.** Surface this one-line user-facing fix and stop:
47
+
48
+ > Your channel needs to be allowlisted for slash commands. Run on the gateway host: `jq '.channels.<channel>.allowFrom = ["<your-id>"]' ~/.openclaw/openclaw.json | sponge ~/.openclaw/openclaw.json && docker restart <your-container-name>` (Docker self-host) — substitute `<channel>` with your channel name (e.g. `telegram`, `slack`), `<your-id>` with your user ID for that channel (Telegram: `@username` or numeric `chat_id`; Slack: workspace member ID), and `<your-container-name>` with your gateway container name (`docker ps` shows it). On a managed service, ask the service's support to enable slash commands for your channel. Reply `done` once it's back up.
49
+
50
+ For non-Docker / native deployments substitute `openclaw gateway restart` for the `docker restart` portion. As a session-level fallback while restart is gated, try `/new` (starts a fresh session within the same gateway process, may pick up freshly-bound tools without a full restart).
39
51
 
40
- 2. **Wait for auto-reload — do NOT restart the gateway yourself.** 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. If it does NOT appear within 10s, the user has `gateway.reload.mode = off` — fall back: tell the user verbatim *"Auto-reload didn't fire. Please restart your OpenClaw gateway: `openclaw gateway restart` (native), `docker restart <your-container-name>` (Docker self-host — substitute the actual name; `docker ps` shows it), or your managed service's restart control. Reply `done` once it's back up."* Do NOT invoke the restart yourself your shell is running inside the gateway you'd be restarting (catch-22). Wait for the user's `done`.
52
+ **Forbidden vocabulary** (these are anti-patterns when a tool is missing post-install): "Should I /restart", "Do you have a public URL", "Want me to restart", "Let me check if the tool is bound", "I need permission to restart". Just issue `/restart` immediately and announce.
41
53
 
42
- 3. **Verify `totalreclaw_pair` is bound.** If still missing after the user said `done`, tell them the restart didn't take effect and to retry. Do not loop the install.
54
+ 3. **Verify `totalreclaw_pair` is bound.** If still missing after `/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.
43
55
 
44
56
  4. **Check for existing credentials.** Run `test -s ~/.totalreclaw/credentials.json`. If it exists and is non-empty, confirm *"TotalReclaw is already set up."* and stop.
45
57
 
package/dist/embedding.js CHANGED
@@ -53,10 +53,26 @@ export function configureEmbedder(cfg) {
53
53
  function defaultCacheRoot() {
54
54
  return path.join(os.homedir(), '.totalreclaw', 'embedder');
55
55
  }
56
+ /**
57
+ * Last-known-good embedder bundle tag. Used ONLY as a hard-fallback when
58
+ * `configureEmbedder()` is never called by the orchestrator (defensive
59
+ * path — production code always wires it via index.ts register()).
60
+ *
61
+ * 3.3.4-rc.1 — pinned to v3.3.3-rc.1 because that is the most recent
62
+ * release at fix-time with a published `embedder-v1.tar.gz` asset. Earlier
63
+ * fallback `'0.0.0-dev'` (rc.22 → 3.3.3-rc.1) hard-coded a placeholder
64
+ * that resolved to a 404 GitHub Release URL; QA on 3.3.3-rc.1 (Pedro
65
+ * 2026-04-30) caught it because the cascade-cause (broken
66
+ * `readPluginVersion()` resolution) made the fallback fire on every cold
67
+ * start. Bumping this constant per RC is fine — the publish workflow auto-
68
+ * publishes the bundle for every RC tag (see scripts/build-embedder-
69
+ * bundle.mjs in the public repo).
70
+ */
71
+ const LAST_KNOWN_GOOD_RC_TAG = '3.3.3-rc.1';
56
72
  function activeRuntimeConfig() {
57
73
  if (runtimeConfig)
58
74
  return runtimeConfig;
59
- return { cacheRoot: defaultCacheRoot(), rcTag: '0.0.0-dev' };
75
+ return { cacheRoot: defaultCacheRoot(), rcTag: LAST_KNOWN_GOOD_RC_TAG };
60
76
  }
61
77
  /**
62
78
  * 3.3.3-rc.1 (issue #187 — ONNX decouple): prefetch the embedder bundle
@@ -74,9 +74,17 @@ export function ensureMemoryHeaderFile(workspace, header, markerSubstring = 'Tot
74
74
  * Read the plugin's own version string from `package.json`.
75
75
  *
76
76
  * Behaviour:
77
- * - Resolves `package.json` next to the caller-provided directory
77
+ * - Tries `package.json` next to the caller-provided directory first
78
78
  * (typically `path.dirname(fileURLToPath(import.meta.url))` from the
79
- * caller).
79
+ * caller — i.e., the directory of the running ESM module).
80
+ * - If that misses, walks up to 5 parent directories looking for a
81
+ * `package.json` whose `name` is `@totalreclaw/totalreclaw`. This
82
+ * covers the OpenClaw plugin sandbox case where the loaded module
83
+ * lives at `<pluginRoot>/dist/index.js` while `package.json` lives
84
+ * at `<pluginRoot>/package.json` (3.3.4-rc.1 fix — without this
85
+ * walk-up, the `.loaded.json` manifest gets `version=unknown` and
86
+ * all RC-gated logic that depends on the version string fails
87
+ * silently in production OpenClaw deployments).
80
88
  * - Returns the `version` field, or `null` on any I/O / parse error.
81
89
  *
82
90
  * Used by the RC-gated `totalreclaw_report_qa_bug` tool registration in
@@ -87,13 +95,51 @@ export function ensureMemoryHeaderFile(workspace, header, markerSubstring = 'Tot
87
95
  * helper — see the file-header guardrail.
88
96
  */
89
97
  export function readPluginVersion(packageJsonDir) {
98
+ // Direct hit (source-tree dev path; tests).
99
+ const direct = tryReadPluginPackageJson(path.join(packageJsonDir, 'package.json'));
100
+ if (direct)
101
+ return direct;
102
+ // Walk up — the running ESM module typically lives at
103
+ // `<pluginRoot>/dist/index.js`, so `packageJsonDir` is `<pluginRoot>/dist`
104
+ // and `package.json` is one level up. Bound the walk so a misconfigured
105
+ // path doesn't traverse the entire filesystem; 5 levels is more than
106
+ // enough for any realistic plugin layout (dist/, dist/cjs/, build/lib/).
107
+ let current = packageJsonDir;
108
+ for (let depth = 0; depth < 5; depth++) {
109
+ const parent = path.dirname(current);
110
+ if (parent === current)
111
+ break; // root reached
112
+ const candidate = path.join(parent, 'package.json');
113
+ const version = tryReadPluginPackageJson(candidate);
114
+ if (version)
115
+ return version;
116
+ current = parent;
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * Try to read `package.json` at `pkgPath`. Returns the `version` only if
122
+ * the file's `name` field matches `@totalreclaw/totalreclaw` — guards
123
+ * against accidentally returning the version of an outer host-package
124
+ * (e.g. when the plugin is bundled inside a parent app's tree).
125
+ *
126
+ * If `name` is absent (legacy / minimal package.json), accept the version
127
+ * anyway as a fallback — this is the existing behaviour preserved for
128
+ * anyone who manually trimmed their package.json.
129
+ */
130
+ function tryReadPluginPackageJson(pkgPath) {
90
131
  try {
91
- const pkgPath = path.join(packageJsonDir, 'package.json');
92
132
  if (!fs.existsSync(pkgPath))
93
133
  return null;
94
134
  const raw = fs.readFileSync(pkgPath, 'utf-8');
95
135
  const parsed = JSON.parse(raw);
96
- return typeof parsed.version === 'string' ? parsed.version : null;
136
+ if (typeof parsed.version !== 'string')
137
+ return null;
138
+ if (typeof parsed.name === 'string' && parsed.name !== '@totalreclaw/totalreclaw') {
139
+ // Wrong package — keep walking.
140
+ return null;
141
+ }
142
+ return parsed.version;
97
143
  }
98
144
  catch {
99
145
  return null;
package/dist/index.js CHANGED
@@ -511,10 +511,26 @@ let firstRunWelcomeShown = false;
511
511
  *
512
512
  * Goal: a fresh QA tester can't accidentally use an RC build for real data
513
513
  * without seeing a clear "RC = staging, no SLA, may be wiped" warning.
514
- * One-shot at the first `before_agent_start` after register(); never spams
515
- * per-turn. A fresh gateway restart re-fires it once.
514
+ * One-shot at the first `before_agent_start` whose `prependContext` actually
515
+ * lands on the LLM (3.3.4-rc.1 — see fix below). A fresh gateway restart
516
+ * re-fires it once.
517
+ *
518
+ * 3.3.4-rc.1 fix: through 3.3.3-rc.1 this flag was set to true as soon as
519
+ * the banner BLOCK was built — but multiple hook return paths returned
520
+ * `undefined` (zero-match cases), so the banner block was silently dropped
521
+ * AND the flag was flipped, suppressing all subsequent attempts. Now the
522
+ * flag flips ONLY when a return path actually includes the block in its
523
+ * `prependContext`, via the `markBannerDelivered()` closure.
516
524
  */
517
525
  let stagingBannerShown = false;
526
+ /**
527
+ * 3.3.4-rc.1 — operator-facing "this is an RC build" log fires once per
528
+ * gateway process, independent of whether the user-facing banner has
529
+ * been delivered yet. Without this split, the warn-log was tied to the
530
+ * same flag as the user-facing banner and got dropped together when
531
+ * the hook returned `undefined`.
532
+ */
533
+ let stagingBannerLogged = false;
518
534
  /**
519
535
  * Derive keys from the recovery phrase, load credentials, and register with
520
536
  * the server if this is the first run.
@@ -2410,18 +2426,32 @@ const plugin = {
2410
2426
  }
2411
2427
  // 3.3.1-rc.22 — wire the lazy-embedder runtime config so the first
2412
2428
  // `generateEmbedding()` call knows where to cache the bundle and
2413
- // which RC's GitHub Release to fetch from. `pluginVersion` may be
2414
- // `null` if package.json is unreadable; the embedder defaults to
2415
- // a "0.0.0-dev" tag in that case.
2416
- try {
2417
- configureEmbedder({
2418
- cacheRoot: CONFIG.embedderCachePath,
2419
- rcTag: pluginVersion ?? '0.0.0-dev',
2420
- });
2429
+ // which RC's GitHub Release to fetch from.
2430
+ //
2431
+ // 3.3.4-rc.1 when readPluginVersion() returns null (rare, but
2432
+ // possible if package.json is unreadable inside the OpenClaw
2433
+ // sandbox), we previously passed the literal `'0.0.0-dev'` which
2434
+ // resolves to a 404 GitHub Release URL. Now we let `embedding.ts`
2435
+ // fall back to its `LAST_KNOWN_GOOD_RC_TAG` constant by SKIPPING
2436
+ // the configure call entirely in the null case — the
2437
+ // `activeRuntimeConfig()` helper picks the constant up. This way
2438
+ // the constant lives in one place (embedding.ts) and the orch-
2439
+ // estrator just doesn't fight it.
2440
+ if (pluginVersion) {
2441
+ try {
2442
+ configureEmbedder({
2443
+ cacheRoot: CONFIG.embedderCachePath,
2444
+ rcTag: pluginVersion,
2445
+ });
2446
+ }
2447
+ catch (err) {
2448
+ const msg = err instanceof Error ? err.message : String(err);
2449
+ api.logger.warn(`TotalReclaw: configureEmbedder failed (will use defaults): ${msg}`);
2450
+ }
2421
2451
  }
2422
- catch (err) {
2423
- const msg = err instanceof Error ? err.message : String(err);
2424
- api.logger.warn(`TotalReclaw: configureEmbedder failed (will use defaults): ${msg}`);
2452
+ else {
2453
+ api.logger.warn('TotalReclaw: pluginVersion unresolved embedder will fall back to LAST_KNOWN_GOOD_RC_TAG. ' +
2454
+ 'Investigate package.json resolution; see fs-helpers.readPluginVersion docs.');
2425
2455
  }
2426
2456
  // 3.3.3-rc.1 (issue #187 — ONNX decouple): kick off a non-blocking
2427
2457
  // bundle prefetch so the ~700 MB embedder tarball starts streaming
@@ -2576,11 +2606,36 @@ const plugin = {
2576
2606
  });
2577
2607
  // 3.3.0 — `openclaw totalreclaw pair [generate|import]` attaches
2578
2608
  // alongside the existing `onboard` + `status` subcommands.
2609
+ //
2610
+ // 3.3.4-rc.1 — wire `runRelayPairCli` so the CLI defaults to the
2611
+ // same relay-brokered URL surface the agent tool uses. The local
2612
+ // (gateway-loopback) flow is still available via `--local`. See
2613
+ // pair-cli.ts header for the rationale.
2579
2614
  const { registerPairCli } = await import('./pair-cli.js');
2580
2615
  registerPairCli(program, {
2581
2616
  sessionsPath: CONFIG.pairSessionsPath,
2582
2617
  renderPairingUrl: (session) => buildPairingUrl(api, session),
2583
2618
  logger: api.logger,
2619
+ runRelayPairCli: async (cliMode, runOpts) => {
2620
+ const { runRelayPairCli } = await import('./pair-cli-relay.js');
2621
+ return runRelayPairCli(cliMode, {
2622
+ relayBaseUrl: CONFIG.pairRelayUrl,
2623
+ credentialsPath: CREDENTIALS_PATH,
2624
+ onboardingStatePath: CONFIG.onboardingStatePath,
2625
+ logger: api.logger,
2626
+ pluginVersion: pluginVersion ?? '3.3.4-rc.1',
2627
+ deriveScopeAddress: async (mnemonic) => {
2628
+ try {
2629
+ return await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
2630
+ }
2631
+ catch (err) {
2632
+ api.logger.warn(`relay pair-cli: scope-address derivation failed (will retry lazily): ${err instanceof Error ? err.message : String(err)}`);
2633
+ return undefined;
2634
+ }
2635
+ },
2636
+ ...runOpts,
2637
+ });
2638
+ },
2584
2639
  });
2585
2640
  }, { commands: ['totalreclaw'] });
2586
2641
  }
@@ -4631,9 +4686,23 @@ const plugin = {
4631
4686
  // Background task — writes credentials.json + flips state when
4632
4687
  // the browser completes the flow. Tool handler returns
4633
4688
  // immediately so the agent can tell the user the URL + PIN.
4689
+ //
4690
+ // 3.3.4-rc.2 (Pedro QA — pair flow stuck-session) — wrap the
4691
+ // WS-await in a 60s hard timeout. The relay drops sessions on
4692
+ // `ws_close` (separately patched relay-side); without this
4693
+ // bound the background task hangs in `waitNextMessage` for the
4694
+ // full 5-minute session TTL before resolving, and downstream
4695
+ // tooling that polls the gateway for completion sees stale
4696
+ // `processing` state at 129s/159s/189s. 60s is the user-side
4697
+ // deadline we surface in chat: long enough for a slow scan-
4698
+ // and-paste, short enough that a fresh URL request is the
4699
+ // obvious next step. Structured `timed_out` error in the log
4700
+ // lets ops grep for the failure mode independently of generic
4701
+ // ws-close errors.
4702
+ const PAIR_TOOL_HARD_TIMEOUT_MS = 60_000;
4634
4703
  void (async () => {
4635
4704
  try {
4636
- await awaitPhraseUpload(remoteSession, {
4705
+ const phraseUploadPromise = awaitPhraseUpload(remoteSession, {
4637
4706
  phraseValidator: (p) => validateMnemonic(p, wordlist),
4638
4707
  completePairing: async ({ mnemonic }) => {
4639
4708
  try {
@@ -4673,7 +4742,37 @@ const plugin = {
4673
4742
  return { state: 'error', error: msg };
4674
4743
  }
4675
4744
  },
4745
+ // 3.3.4-rc.2 — also pass through to awaitPhraseUpload so its
4746
+ // internal `waitNextMessage` timer matches the outer race.
4747
+ timeoutMs: PAIR_TOOL_HARD_TIMEOUT_MS,
4748
+ });
4749
+ // 3.3.4-rc.2 — outer Promise.race guard. Resolves to a
4750
+ // sentinel ({ status: 'timed_out', ... }) so the catch
4751
+ // handler can distinguish a hard-timeout from a generic
4752
+ // ws-close error and surface it explicitly.
4753
+ const TIMEOUT_SENTINEL = {
4754
+ status: 'timed_out',
4755
+ message: `Pair flow timed out (${PAIR_TOOL_HARD_TIMEOUT_MS / 1000}s) — generate a new URL with totalreclaw_pair.`,
4756
+ };
4757
+ let hardTimer;
4758
+ const hardTimeoutPromise = new Promise((resolve) => {
4759
+ hardTimer = setTimeout(() => resolve(TIMEOUT_SENTINEL), PAIR_TOOL_HARD_TIMEOUT_MS);
4676
4760
  });
4761
+ try {
4762
+ const raced = await Promise.race([
4763
+ phraseUploadPromise,
4764
+ hardTimeoutPromise,
4765
+ ]);
4766
+ if (raced &&
4767
+ typeof raced === 'object' &&
4768
+ raced.status === 'timed_out') {
4769
+ api.logger.warn(`totalreclaw_pair(relay): hard timeout — ${raced.message} (token=${remoteSession.token.slice(0, 8)}…)`);
4770
+ }
4771
+ }
4772
+ finally {
4773
+ if (hardTimer)
4774
+ clearTimeout(hardTimer);
4775
+ }
4677
4776
  }
4678
4777
  catch (bgErr) {
4679
4778
  // Expected on TTL expiry / user-aborts — log at warn, not error.
@@ -4991,7 +5090,24 @@ const plugin = {
4991
5090
  // Build a one-shot prefix when the bundled default points at staging
4992
5091
  // AND the user hasn't overridden via env. This prefix is prepended
4993
5092
  // to whichever context block the rest of the hook produces.
5093
+ //
5094
+ // 3.3.4-rc.1 — fix: previously `stagingBannerShown` was set to
5095
+ // `true` AS SOON AS the block was built. If the rest of the hook
5096
+ // then returned `undefined` (e.g. zero memory matches on the first
5097
+ // turn — multiple paths around lines 6103-6325 do this), the
5098
+ // banner block was silently discarded AND the flag was already
5099
+ // flipped, so subsequent before_agent_start invocations never
5100
+ // reconstructed it. Net effect: QA on 3.3.3-rc.1 (Pedro
5101
+ // 2026-04-30) saw NO banner emitted across an entire conversation
5102
+ // even though the build was bound to staging.
5103
+ //
5104
+ // Fix: build the block on every call until it is actually
5105
+ // delivered (i.e., until at least one return path included it
5106
+ // in `prependContext`). The flag flips at the bottom of this
5107
+ // hook in `markBannerDelivered()` once we know the prependContext
5108
+ // path was taken.
4994
5109
  let stagingBannerBlock = '';
5110
+ let stagingBannerSuppressed = false;
4995
5111
  if (!stagingBannerShown) {
4996
5112
  try {
4997
5113
  const usingStagingDefault = CONFIG.serverUrl.includes('api-staging.totalreclaw.xyz');
@@ -5004,9 +5120,11 @@ const plugin = {
5004
5120
  'For production, install the stable release: `openclaw plugins install ' +
5005
5121
  '@totalreclaw/totalreclaw` (no `@rc` suffix). To pin a custom server, set ' +
5006
5122
  '`TOTALRECLAW_SERVER_URL=https://api.totalreclaw.xyz` in your env.\n\n';
5007
- stagingBannerShown = true;
5008
- api.logger.warn('TotalReclaw: RC/staging build active (api-staging.totalreclaw.xyz). ' +
5009
- 'See docs/guides/release-process.md for the RC=staging / stable=production rule.');
5123
+ // Do NOT set stagingBannerShown=true here — see comment above.
5124
+ // Logger emits once per gateway process; this is fine to
5125
+ // gate on the same flag because the logger is operator-
5126
+ // facing, not user-facing.
5127
+ stagingBannerSuppressed = true;
5010
5128
  }
5011
5129
  else {
5012
5130
  // Non-RC artifact OR user override — never fire the banner this
@@ -5019,6 +5137,33 @@ const plugin = {
5019
5137
  stagingBannerShown = true;
5020
5138
  }
5021
5139
  }
5140
+ // Operator-facing log: once per process, when we DETECT the
5141
+ // staging build (banner-shown semantics are about user
5142
+ // delivery; this log is independent).
5143
+ if (stagingBannerSuppressed && !stagingBannerLogged) {
5144
+ stagingBannerLogged = true;
5145
+ api.logger.warn('TotalReclaw: RC/staging build active (api-staging.totalreclaw.xyz). ' +
5146
+ 'See docs/guides/release-process.md for the RC=staging / stable=production rule.');
5147
+ }
5148
+ /**
5149
+ * Helper — invoked inline at any `prependContext` site that
5150
+ * wants to lead with the staging banner. Returns the banner
5151
+ * string AND atomically marks the banner as delivered, so
5152
+ * subsequent hook calls in the same gateway-process lifetime
5153
+ * skip re-emission. Returns '' (empty) when no banner is due
5154
+ * (stable build, user override, or already delivered).
5155
+ *
5156
+ * Use this at every prependContext callsite that takes the
5157
+ * banner; do NOT inline `stagingBannerBlock` on its own — the
5158
+ * 3.3.4-rc.1 bug fix requires the marker flip to be coupled
5159
+ * to the actual delivery.
5160
+ */
5161
+ const consumeBannerForPrepend = () => {
5162
+ if (stagingBannerBlock === '')
5163
+ return '';
5164
+ stagingBannerShown = true;
5165
+ return stagingBannerBlock;
5166
+ };
5022
5167
  await ensureInitialized(api.logger);
5023
5168
  // 3.2.0 onboarding pending: emit a non-secret guidance banner so
5024
5169
  // the LLM knows how to respond when the user asks about setup.
@@ -5047,7 +5192,7 @@ const plugin = {
5047
5192
  api.logger.warn(`First-run welcome check failed: ${msg}`);
5048
5193
  }
5049
5194
  return {
5050
- prependContext: stagingBannerBlock +
5195
+ prependContext: consumeBannerForPrepend() +
5051
5196
  welcomeBlock +
5052
5197
  '## TotalReclaw setup pending\n\n' +
5053
5198
  'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
@@ -5146,7 +5291,7 @@ const plugin = {
5146
5291
  if (injectResult.promptText) {
5147
5292
  api.logger.info(`Digest injection: state=${injectResult.state}`);
5148
5293
  return {
5149
- prependContext: stagingBannerBlock +
5294
+ prependContext: consumeBannerForPrepend() +
5150
5295
  `## Your Memory\n\n${injectResult.promptText}` + welcomeBack + billingWarning,
5151
5296
  };
5152
5297
  }
@@ -5188,7 +5333,7 @@ const plugin = {
5188
5333
  const querySimilarity = cosineSimilarity(queryEmbedding, lastQueryEmbedding);
5189
5334
  if (querySimilarity > SEMANTIC_SKIP_THRESHOLD) {
5190
5335
  const lines = cachedFacts.slice(0, 8).map((f, i) => `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`);
5191
- return { prependContext: stagingBannerBlock + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5336
+ return { prependContext: consumeBannerForPrepend() + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5192
5337
  }
5193
5338
  }
5194
5339
  // 3. Merge trapdoors — always include word trapdoors for small-dataset coverage.
@@ -5197,7 +5342,7 @@ const plugin = {
5197
5342
  // If we have cached facts and no trapdoors, return cached facts.
5198
5343
  if (allTrapdoors.length === 0 && cachedFacts.length > 0) {
5199
5344
  const lines = cachedFacts.slice(0, 8).map((f, i) => `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`);
5200
- return { prependContext: stagingBannerBlock + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5345
+ return { prependContext: consumeBannerForPrepend() + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5201
5346
  }
5202
5347
  if (allTrapdoors.length === 0)
5203
5348
  return undefined;
@@ -5212,7 +5357,7 @@ const plugin = {
5212
5357
  // Subgraph query failed -- fall back to cached facts if available.
5213
5358
  if (cachedFacts.length > 0) {
5214
5359
  const lines = cachedFacts.slice(0, 8).map((f, i) => `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`);
5215
- return { prependContext: stagingBannerBlock + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5360
+ return { prependContext: consumeBannerForPrepend() + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5216
5361
  }
5217
5362
  return undefined;
5218
5363
  }
@@ -5236,7 +5381,7 @@ const plugin = {
5236
5381
  // If subgraph returned no results but we have cache, use cache.
5237
5382
  if (subgraphResults.length === 0) {
5238
5383
  const lines = cachedFacts.slice(0, 8).map((f, i) => `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`);
5239
- return { prependContext: stagingBannerBlock + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5384
+ return { prependContext: consumeBannerForPrepend() + `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
5240
5385
  }
5241
5386
  // 5. Decrypt subgraph results and build reranker input.
5242
5387
  const rerankerCandidates = [];
@@ -5311,7 +5456,7 @@ const plugin = {
5311
5456
  return `${i + 1}. ${typeTag}${m.text} (importance: ${importance}/10, ${age})`;
5312
5457
  });
5313
5458
  const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
5314
- return { prependContext: stagingBannerBlock + contextString + welcomeBack + billingWarning };
5459
+ return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
5315
5460
  }
5316
5461
  // --- Server mode (existing behavior) ---
5317
5462
  // 1. Generate word trapdoors from the user prompt.
@@ -5393,7 +5538,7 @@ const plugin = {
5393
5538
  return `${i + 1}. ${m.text} (importance: ${importance}/10, ${age})`;
5394
5539
  });
5395
5540
  const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
5396
- return { prependContext: stagingBannerBlock + contextString + welcomeBack + billingWarning };
5541
+ return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
5397
5542
  }
5398
5543
  catch (err) {
5399
5544
  // The hook must NEVER throw -- log and return undefined.