@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

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 (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
- description: "End-to-end encrypted, decentralized memory for OpenClaw. Set up an account once, then call totalreclaw_remember / totalreclaw_recall (or the tr CLI under hybrid-primary) instead of writing to MEMORY.md / USER.md / local files. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', any remember / recall request, AND any user statement that contains a preference / fact / decision / commitment about themselves."
4
- version: 3.3.12-rc.9
3
+ description: "End-to-end encrypted, decentralized memory for OpenClaw. A native kind:memory provider recall is automatic via memory_search/memory_get, and facts are captured in the background. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', any recall request ('what do you remember about me', 'what's my X'), AND any explicit remember request ('remember X', 'save X')."
4
+ version: 3.3.12-rc.23
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
@@ -22,55 +22,61 @@ metadata:
22
22
  - persistent-context
23
23
  ---
24
24
 
25
- # TotalReclaw — OpenClaw plugin
25
+ # TotalReclaw — OpenClaw native memory provider
26
26
 
27
- End-to-end encrypted memory stored across a decentralized network (not on a single company server). The plugin handles install + account-setup autonomously; this skill tells the agent what to call and when.
27
+ End-to-end encrypted memory stored across a decentralized network (not on a single company server). TotalReclaw is OpenClaw's **native `kind:"memory"` provider**: recall runs through the conventional `memory_search` / `memory_get` tools (the same surface `active-memory` uses), and **facts are captured automatically in the background** — you do not call a tool to remember each fact. This skill tells you the install + account-setup flow and when to drive recall explicitly.
28
28
 
29
- ## TOP RULE MEMORY STORAGE
29
+ ## How memory works here (read first)
30
30
 
31
- When the user states ANYTHING about themselves preferences, decisions, identity, work, location, plans, tools, commitments call `totalreclaw_remember` (or `tr remember --json "<fact>"` under hybrid-primary). NEVER write user facts to `MEMORY.md`, `USER.md`, or any local file. Those are agent-persona configuration; user memories live encrypted on-chain.
31
+ - **Recall is native.** `memory_search` and `memory_get` are the memory tools. OpenClaw's `active-memory` sub-agent routes recall through them and the plugin injects guidance telling you when to search. When the user asks about themselves ("what's my X?", "do I X?", "what do you remember?"), call `memory_search`. If 0 results, say so plainly don't guess from session history.
32
+ - **Capture is automatic.** The plugin extracts facts from the conversation in the background (trajectory poller). You do **not** call a tool on every preference/fact the user states — that happens for you. Never write user facts to `MEMORY.md`, `USER.md`, or any local file; those are agent-persona config, user memories live encrypted on-chain.
33
+ - **Explicit remember only on request.** If the user explicitly says "remember X" / "save X" / "note X" / "don't forget X", use the CLI: `tr remember --json "X"`. One call per distinct fact.
32
34
 
33
- **Trigger immediately, no "should I?":** "I prefer / like / love / hate X", "my favorite is X", "I'm X", "my name is X", "I live / work at X", "I use X", "I decided X", "I'm going to X", "from now on X", "we agreed X", "I'll X by Y", "remind me to X", "remember that X", "save X", "note X". If a message contains MULTIPLE facts, call `totalreclaw_remember` once per fact — never blob.
34
-
35
- When the user asks about themselves ("what's my X?", "do I X?", "what do you remember?"), call `totalreclaw_recall` FIRST. If 0 results, say so plainly — don't guess from session history.
36
-
37
- Do not describe storage as `"local"`, `"local-only"`, `"stored locally"`, `"on disk"`, `"local memory"`, `"local storage"`, or `"without an account"` — memories are decentralized on-chain (not on any single company server) and an account is required to derive the encryption key.
35
+ Do not describe storage as `"local"`, `"local-only"`, `"stored locally"`, `"on disk"`, or `"without an account"` memories are decentralized on-chain (not on any single company server) and an account is required to derive the encryption key.
38
36
 
39
37
  ## Phrase safety (HARD)
40
38
 
41
39
  The 12-word recovery phrase MUST NEVER enter chat or the agent's LLM context. The plugin's browser-based pair flow handles phrase generation and import. Never echo it, never ask the user to paste it, never pass it to any tool. If the user pastes one anyway, tell them it's compromised and re-pair for a fresh wallet.
42
40
 
41
+ **HARD guardrail — install/reinstall/upgrade flows:** During install, reinstall, upgrade, or any verification steps, the agent MUST NOT read, list, cat, show, display, or inspect `credentials.json`, `~/.totalreclaw/credentials.json`, `~/.totalreclaw*/credentials.json`, `~/.openclaw/plugins/totalreclaw/credentials.json`, or ANY credentials/phrase/key/secrets file — for ANY reason, including "verifying the install." These files contain encryption key material and Smart Account addresses that MUST NOT enter the LLM context. Never run `ls`, `find`, `cat`, `show`, or similar commands against plugin directories to "check files."
42
+
43
43
  ## Install
44
44
 
45
45
  ```
46
- openclaw plugins install @totalreclaw/totalreclaw@rc
46
+ openclaw plugins install @totalreclaw/totalreclaw
47
47
  openclaw skills install totalreclaw
48
48
  ```
49
49
 
50
- Plugin is published on npm + ClawHub (`p-diogo`, GitHub-verified). Stable: drop `@rc`. Under hybrid-primary, the `tr` CLI is the primary path; native `totalreclaw_*` tools are also bound when OpenClaw exposes themuse whichever your runtime binds.
51
-
52
- ## Setup (plugin-driven)
50
+ Published on npm + ClawHub (`p-diogo`, GitHub-verified). For a release candidate, pin with `@rc`; stable resolves from `latest`. The plugin self-applies any gateway config it needs on load and auto-reloads (see Restart below)no manual gateway restart.
53
51
 
54
- The plugin auto-creates a pair session on first load when no credentials exist, writes `{url, pin, sid, expires_at_ms}` to `~/.totalreclaw/.pair-pending.json`, and injects a `before_agent_start` context block to you. **When you see that block, surface the URL and PIN to the user VERBATIM — read from the injected context; never invent values.** Confirm on pair completion: `✓ TotalReclaw set up.` (If credentials already exist: `✓ TotalReclaw is already set up.`)
52
+ ## Setup (pair user-initiated QR)
55
53
 
56
- If a restart is needed (rare — plugin self-handles via gateway config-watcher), issue the literal slash command `/totalreclaw-restart`. Never `kill`, `docker restart`, or prompt the user to restart.
54
+ Pairing is a deliberate, user-initiated QR flow (the plugin does **not** auto-pair on load). When credentials don't exist:
57
55
 
58
- ## Hybrid-primary CLI
56
+ 1. Run `tr pair --json` → returns `{"url":"https://…/pair/p/<id>#pk=…","pin":"123456","expires_at_ms":…}`.
57
+ 2. Surface the **url** and **pin** to the user verbatim (read from the JSON; never invent values): "Open `<url>` in your browser, enter PIN `<pin>`, and generate or paste your 12-word recovery phrase. Reply done once it's sealed."
58
+ 3. The user completes in the browser (phrase is generated/imported browser-side, encrypted, posted to the relay — never touches this chat). On completion `~/.totalreclaw/credentials.json` is written.
59
+ 4. Confirm: `✓ TotalReclaw set up.` (If credentials already exist when first asked: `✓ TotalReclaw is already set up.`)
59
60
 
60
- OpenClaw 2026.5.x may strip non-bundled tool bindings (issue #223). Never rely on `totalreclaw_*` tool binding alone. Fall back via shell:
61
+ Pair is UNCONDITIONAL when no credentials the user installing/requesting setup IS the consent; do not add a second consent gate. `tr --help` lists every subcommand.
61
62
 
62
63
  ```bash
63
64
  TR_CLI="$(ls -d ~/.openclaw/npm/node_modules/@totalreclaw/totalreclaw/dist/tr-cli.js ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js 2>/dev/null | head -1)"
64
- node "$TR_CLI" status --json # {"version":"...","hybrid_mode":true,"tool_count":17}
65
- node "$TR_CLI" remember --json "I work at Graph Foundation"
66
- node "$TR_CLI" recall --json "where does the user work" --limit 5 # {"results":[{"text":"...","score":0.8}]}
67
- node "$TR_CLI" pair --json # {"url":"...","pin":"123456","expires_at_ms":...} — plugin's auto-pair is preferred; only use this for re-pair
65
+ node "$TR_CLI" pair --json # start/re-pair
66
+ node "$TR_CLI" status --json # {"version":"…","paired":true,…}
68
67
  ```
69
68
 
70
- Pair is UNCONDITIONAL when no credentials — pasting the install URL IS the consent. `tr --help` lists every subcommand.
69
+ ## Restart (agent-driven, never user-manual)
70
+
71
+ The plugin self-reloads: after install it writes the gateway config it needs and signals `SIGUSR1` to its own PID so the gateway picks it up without a manual restart. **If `memory_search`/`memory_get` are not bound** (tools didn't take effect on first load), issue the literal slash command `/totalreclaw-restart` **autonomously** — announce it in one line, never ask the user to authorize it, never stall, never `kill` / `docker restart` / prompt the user to restart. This mirrors the autonomous-restart pattern other runtimes use; the user should never perform a manual restart.
72
+
73
+ ## Tools + CLI surface
74
+
75
+ **Recall (native memory contract — agent-facing):** `memory_search`, `memory_get`.
71
76
 
72
- ## Tools (17 native; same surface via `tr`)
77
+ **Explicit capture + curation (CLI `tr`, i.e. `node "$TR_CLI" …`):**
78
+ `tr remember` (explicit write) · `tr pin` / `tr unpin` · `tr retype` · `tr set_scope` · `tr status` · `tr export` · `tr pair`. Import + plan upgrade run via the gateway subcommand: `openclaw totalreclaw import from <source> --file <path> [--json]`, `openclaw totalreclaw upgrade [--json]`, `openclaw totalreclaw import status|abort`.
73
79
 
74
- `totalreclaw_remember` · `totalreclaw_recall` · `totalreclaw_forget` · `totalreclaw_pair` · `totalreclaw_pin` · `totalreclaw_unpin` · `totalreclaw_retype` · `totalreclaw_set_scope` · `totalreclaw_export` · `totalreclaw_import_from` · `totalreclaw_import_batch` · `totalreclaw_consolidate` · `totalreclaw_status` · `totalreclaw_upgrade` · `totalreclaw_migrate` · `totalreclaw_onboarding_start` · `totalreclaw_report_qa_bug` (RC only).
80
+ The legacy `totalreclaw_*` agent tools and the `tr recall` CLI are retired recall is `memory_search`, explicit capture is `tr remember`. If a stale guide references them, follow this SKILL instead.
75
81
 
76
- Full guides: <https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/openclaw-setup.md>
82
+ Full guide: <https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/openclaw-setup.md>
package/api-client.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * TotalReclaw Plugin - HTTP API Client
3
3
  *
4
- * Communicates with the TotalReclaw server over JSON/HTTP. Uses Node.js
5
- * built-in `fetch` (available since Node 18).
4
+ * Communicates with the TotalReclaw server over JSON/HTTP. All wire I/O
5
+ * goes through `relay.ts` (the plugin's single network site); this module
6
+ * owns request/response shape, status-checking, and error context.
6
7
  *
7
8
  * All authenticated endpoints expect:
8
9
  * Authorization: Bearer <hex-encoded-auth-key>
@@ -16,6 +17,7 @@
16
17
  */
17
18
 
18
19
  import { buildRelayHeaders } from './relay-headers.js';
20
+ import { relayFetch } from './relay.js';
19
21
 
20
22
  // ---------------------------------------------------------------------------
21
23
  // Request / Response Types
@@ -131,7 +133,8 @@ export function createApiClient(serverUrl: string) {
131
133
  authKeyHash: string,
132
134
  saltHex: string,
133
135
  ): Promise<{ user_id: string }> {
134
- const res = await fetch(`${baseUrl}/v1/register`, {
136
+ const res = await relayFetch({
137
+ url: `${baseUrl}/v1/register`,
135
138
  method: 'POST',
136
139
  headers: buildRelayHeaders({ 'Content-Type': 'application/json' }),
137
140
  body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
@@ -165,7 +168,8 @@ export function createApiClient(serverUrl: string) {
165
168
  facts: StoreFactPayload[],
166
169
  authKeyHex: string,
167
170
  ): Promise<{ ids: string[]; duplicate_ids?: string[] }> {
168
- const res = await fetch(`${baseUrl}/v1/store`, {
171
+ const res = await relayFetch({
172
+ url: `${baseUrl}/v1/store`,
169
173
  method: 'POST',
170
174
  headers: buildRelayHeaders({
171
175
  'Content-Type': 'application/json',
@@ -203,7 +207,8 @@ export function createApiClient(serverUrl: string) {
203
207
  maxCandidates: number,
204
208
  authKeyHex: string,
205
209
  ): Promise<SearchCandidate[]> {
206
- const res = await fetch(`${baseUrl}/v1/search`, {
210
+ const res = await relayFetch({
211
+ url: `${baseUrl}/v1/search`,
207
212
  method: 'POST',
208
213
  headers: buildRelayHeaders({
209
214
  'Content-Type': 'application/json',
@@ -234,7 +239,8 @@ export function createApiClient(serverUrl: string) {
234
239
  * @param authKeyHex Hex-encoded raw auth key for Bearer header.
235
240
  */
236
241
  async deleteFact(factId: string, authKeyHex: string): Promise<void> {
237
- const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
242
+ const res = await relayFetch({
243
+ url: `${baseUrl}/v1/facts/${encodeURIComponent(factId)}`,
238
244
  method: 'DELETE',
239
245
  headers: buildRelayHeaders({
240
246
  Authorization: `Bearer ${authKeyHex}`,
@@ -259,7 +265,8 @@ export function createApiClient(serverUrl: string) {
259
265
  * @returns The number of facts that were actually deleted.
260
266
  */
261
267
  async batchDelete(factIds: string[], authKeyHex: string): Promise<number> {
262
- const res = await fetch(`${baseUrl}/v1/facts/batch-delete`, {
268
+ const res = await relayFetch({
269
+ url: `${baseUrl}/v1/facts/batch-delete`,
263
270
  method: 'POST',
264
271
  headers: buildRelayHeaders({
265
272
  'Content-Type': 'application/json',
@@ -295,7 +302,8 @@ export function createApiClient(serverUrl: string) {
295
302
  const params = new URLSearchParams({ limit: String(limit) });
296
303
  if (cursor) params.set('cursor', cursor);
297
304
 
298
- const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
305
+ const res = await relayFetch({
306
+ url: `${baseUrl}/v1/export?${params.toString()}`,
299
307
  method: 'GET',
300
308
  headers: buildRelayHeaders({
301
309
  Authorization: `Bearer ${authKeyHex}`,
@@ -325,7 +333,7 @@ export function createApiClient(serverUrl: string) {
325
333
  */
326
334
  async health(): Promise<boolean> {
327
335
  try {
328
- const res = await fetch(`${baseUrl}/health`, { method: 'GET' });
336
+ const res = await relayFetch({ url: `${baseUrl}/health`, method: 'GET' });
329
337
  return res.status === 200;
330
338
  } catch {
331
339
  return false;
package/batch-gate.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Boot-time chain-gate predicate for client-side `executeBatch` UserOp
3
+ * submission. Spec #281 §9 Phase 1 (item imp-16).
4
+ *
5
+ * The gate batches via `executeBatch` on Gnosis (chain 100). After ops-1
6
+ * (2026-06-05) BOTH tiers run on Gnosis, so both now batch — the chain==100
7
+ * check is retained as a safety guard (a non-100 chain, which shouldn't occur
8
+ * after ops-1, falls back to single-fact). The legacy Free ⇒ Base Sepolia
9
+ * (84532) single-fact path is gone with the retired two-tier routing (#402).
10
+ * The `TOTALRECLAW_GNOSIS_BATCH_ENABLED` env var is a hard kill-switch —
11
+ * setting it to `false` disables batching regardless of chain, so ops can
12
+ * revert to single-fact submission without a client redeploy. Behaviour of
13
+ * `shouldBatchOnChain` is unchanged by #402.
14
+ *
15
+ * Read at boot only (module-load time). Per-write reads would re-parse the
16
+ * env on every submission — too expensive for the auto-extraction hot path
17
+ * and pointless because the env doesn't change mid-process.
18
+ *
19
+ * Sibling work-leaves wire `shouldBatchOnChain` into `submitFactBatchOnChain`
20
+ * (TS) and `agent/lifecycle.py` (Python mirror in `batch_gate.py`); this
21
+ * module ships the primitive only.
22
+ *
23
+ * Env read is centralized in entry.ts (env-reading seam, Task 1.3 of the
24
+ * OpenClaw native integration plan, 2026-06-21).
25
+ */
26
+
27
+ import { isGnosisBatchEnabledAtBoot } from './entry.js';
28
+
29
+ const GNOSIS_CHAIN_ID = 100;
30
+
31
+ export function shouldBatchOnChain(chainId: number): boolean {
32
+ if (!isGnosisBatchEnabledAtBoot()) return false;
33
+ return chainId === GNOSIS_CHAIN_ID;
34
+ }
35
+
36
+ export const __testing = {
37
+ readGateForTests(env: NodeJS.ProcessEnv, chainId: number): boolean {
38
+ const enabled = (env.TOTALRECLAW_GNOSIS_BATCH_ENABLED ?? '').toLowerCase() !== 'false';
39
+ if (!enabled) return false;
40
+ return chainId === GNOSIS_CHAIN_ID;
41
+ },
42
+ };
package/billing-cache.ts CHANGED
@@ -11,8 +11,12 @@
11
11
  * This module:
12
12
  * - reads/writes `~/.totalreclaw/billing-cache.json` (path from CONFIG)
13
13
  * - exports `BillingCache`, `BILLING_CACHE_PATH`, `BILLING_CACHE_TTL`
14
- * - keeps the chain-id override in sync with the cached tier so Pro-tier
15
- * UserOps sign against chain 100 and Free-tier stays on 84532
14
+ * - keeps the chain-id override in sync with the relay's authoritative
15
+ * `chain_id` (after ops-1 both tiers are on Gnosis 100; the client consumes
16
+ * the relay value verbatim — see `syncChainIdFromBilling`)
17
+ * - keeps the DataEdge-address override in sync with the relay's
18
+ * authoritative `data_edge_address` (staging vs prod contract; consumed
19
+ * verbatim — see `syncDataEdgeAddressFromBilling`, #460)
16
20
  * - does NOT import anything that performs outbound I/O
17
21
  *
18
22
  * Do NOT add any outbound-request call to this file — a single match for
@@ -23,7 +27,10 @@
23
27
 
24
28
  import fs from 'node:fs';
25
29
  import path from 'node:path';
26
- import { CONFIG, setChainIdOverride } from './config.js';
30
+ import { CONFIG, setChainIdOverride, setDataEdgeAddressOverride } from './config.js';
31
+
32
+ /** A plausible EVM address — anything else from billing is ignored (#460). */
33
+ const DATA_EDGE_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
27
34
 
28
35
  // ---------------------------------------------------------------------------
29
36
  // Constants
@@ -50,6 +57,19 @@ export interface BillingCache {
50
57
  max_facts_per_extraction?: number;
51
58
  max_candidate_pool?: number;
52
59
  };
60
+ /**
61
+ * Authoritative chain id from the relay `/v1/billing/status` response.
62
+ * After ops-1 (2026-06-05) both tiers are on Gnosis (100); the relay is the
63
+ * source of truth, so the client consumes this verbatim (#402).
64
+ */
65
+ chain_id?: number;
66
+ /**
67
+ * Authoritative DataEdge contract address from the relay
68
+ * `/v1/billing/status` response. Staging returns the isolated staging
69
+ * DataEdge, production the prod DataEdge; the client consumes this verbatim
70
+ * so writes and reads land on the same contract (#460).
71
+ */
72
+ data_edge_address?: string;
53
73
  checked_at: number;
54
74
  }
55
75
 
@@ -58,24 +78,49 @@ export interface BillingCache {
58
78
  // ---------------------------------------------------------------------------
59
79
 
60
80
  /**
61
- * Apply the billing tier to the runtime chain override.
81
+ * Apply the relay's authoritative `chain_id` to the runtime chain override.
62
82
  *
63
- * Pro tier chain 100 (Gnosis mainnet). Free tier (or unknown) stays on
64
- * 84532 (Base Sepolia). The relay routes Pro UserOps to Gnosis, so the
65
- * client MUST sign them against chain 100 otherwise the bundler returns
66
- * AA23 (invalid signature). See MCP's equivalent path in mcp/src/index.ts.
83
+ * After ops-1 (2026-06-05) both tiers run on Gnosis (chain 100) and the relay
84
+ * returns an authoritative `chain_id` in `/v1/billing/status`. The client MUST
85
+ * consume that verbatim the old tier→chain derivation (Free 84532 Base
86
+ * Sepolia) was retired two-tier logic that mis-signed FREE-tier UserOps
87
+ * against the wrong chain and queried a Base-Sepolia RPC for a Gnosis-deployed
88
+ * sender, producing deterministic AA10 (#402).
67
89
  *
68
- * Called from `readBillingCache` and `writeBillingCache` so that every cache
69
- * read or write keeps the chain override in sync with the cached tier.
70
- * Idempotent calling with the same tier is a no-op.
90
+ * A missing / non-finite `chain_id` (older relay, partial payload) defaults to
91
+ * 100 never 84532. Called from `readBillingCache` and `writeBillingCache` so
92
+ * every cache read or write keeps the override in sync. Idempotent.
71
93
  */
72
- export function syncChainIdFromTier(tier: string | undefined): void {
73
- if (tier === 'pro') {
74
- setChainIdOverride(100);
75
- } else {
76
- // Free or unknown → reset to the default free-tier chain.
77
- setChainIdOverride(84532);
78
- }
94
+ export function syncChainIdFromBilling(chainId: number | undefined): void {
95
+ setChainIdOverride(typeof chainId === 'number' && Number.isFinite(chainId) ? chainId : 100);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // DataEdge-address sync
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Apply the relay's authoritative `data_edge_address` to the runtime DataEdge
104
+ * override. Mirrors `syncChainIdFromBilling`.
105
+ *
106
+ * The relay routes each environment to its own DataEdge (staging is on-chain
107
+ * isolated). If the client ignores this and uses the WASM-baked default (the
108
+ * PROD DataEdge), writes against the staging relay mine on the prod contract
109
+ * while reads come from the staging subgraph → empty recall + phantom
110
+ * "stored=N" success (#460).
111
+ *
112
+ * A missing / malformed `data_edge_address` (older relay, partial payload,
113
+ * junk) clears the override (`null`) so resolution falls through to the WASM
114
+ * default — never a stale value. Only a plausible address
115
+ * (`0x` + 40 hex) is honored. Called from `readBillingCache` and
116
+ * `writeBillingCache` so every cache read or write keeps the override in sync.
117
+ * Idempotent. The explicit env override (`TOTALRECLAW_DATA_EDGE_ADDRESS`)
118
+ * still wins — it is the first term in `getSubgraphConfig`.
119
+ */
120
+ export function syncDataEdgeAddressFromBilling(address: string | undefined): void {
121
+ setDataEdgeAddressOverride(
122
+ typeof address === 'string' && DATA_EDGE_ADDRESS_RE.test(address) ? address : null,
123
+ );
79
124
  }
80
125
 
81
126
  // ---------------------------------------------------------------------------
@@ -95,8 +140,10 @@ export function readBillingCache(): BillingCache | null {
95
140
  if (!fs.existsSync(BILLING_CACHE_PATH)) return null;
96
141
  const raw = JSON.parse(fs.readFileSync(BILLING_CACHE_PATH, 'utf-8')) as BillingCache;
97
142
  if (!raw.checked_at || Date.now() - raw.checked_at > BILLING_CACHE_TTL) return null;
98
- // Keep chain override in sync with persisted tier across process restarts.
99
- syncChainIdFromTier(raw.tier);
143
+ // Keep chain + DataEdge overrides in sync with the persisted authoritative
144
+ // values across process restarts.
145
+ syncChainIdFromBilling(raw.chain_id);
146
+ syncDataEdgeAddressFromBilling(raw.data_edge_address);
100
147
  return raw;
101
148
  } catch {
102
149
  return null;
@@ -116,7 +163,9 @@ export function writeBillingCache(cache: BillingCache): void {
116
163
  } catch {
117
164
  // Best-effort — don't block on cache write failure.
118
165
  }
119
- // Sync chain override AFTER the write so in-process UserOp signing picks
120
- // up the correct chain immediately, even if the disk write failed.
121
- syncChainIdFromTier(cache.tier);
166
+ // Sync chain + DataEdge overrides AFTER the write so in-process UserOp
167
+ // signing + subgraph reads pick up the correct chain and contract
168
+ // immediately, even if the disk write failed.
169
+ syncChainIdFromBilling(cache.chain_id);
170
+ syncDataEdgeAddressFromBilling(cache.data_edge_address);
122
171
  }
package/claims-helper.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  VALID_MEMORY_VOLATILITIES,
31
31
  VALID_MEMORY_TYPES_V1,
32
32
  } from './extractor.js';
33
+ import { envStringLower } from './entry.js';
33
34
 
34
35
  // Lazy-load WASM. We use createRequire so this module loads cleanly under
35
36
  // both the OpenClaw runtime (CJS-ish tsx) and bare Node ESM (used by tests).
@@ -640,7 +641,7 @@ export type AutoResolveMode = 'active' | 'off' | 'shadow';
640
641
  * @internal Not public config — emergency kill-switch only.
641
642
  */
642
643
  export function resolveAutoResolveMode(): AutoResolveMode {
643
- const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
644
+ const raw = envStringLower('TOTALRECLAW_AUTO_RESOLVE_MODE');
644
645
  if (raw === 'off') return 'off';
645
646
  if (raw === 'shadow') return 'shadow';
646
647
  return 'active';
package/config.ts CHANGED
@@ -102,10 +102,10 @@ export function getSessionId(): string | null {
102
102
 
103
103
  /**
104
104
  * Runtime override for chain ID, set after the relay billing response is
105
- * read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
106
- * (Gnosis mainnet). The relay routes Pro writes to Gnosis, so Pro-tier
107
- * UserOps MUST be signed against chain 100 otherwise the bundler rejects
108
- * the signature with AA23.
105
+ * read. After the ops-1 single-chain migration (2026-05), ALL tiers (free
106
+ * + Pro) are on Gnosis mainnet (chain 100). The default below is 100.
107
+ * The relay routes all writes to Gnosis, so UserOps MUST be signed against
108
+ * chain 100 — otherwise the bundler rejects the signature with AA24.
109
109
  *
110
110
  * See index.ts: after the billing lookup completes, call
111
111
  * `setChainIdOverride(100)` for Pro users. Free users can leave the
@@ -122,6 +122,39 @@ export function __resetChainIdOverrideForTests(): void {
122
122
  _chainIdOverride = null;
123
123
  }
124
124
 
125
+ /**
126
+ * Runtime override for the DataEdge contract address, set after the relay
127
+ * billing response is read. The relay returns an authoritative
128
+ * `data_edge_address` in `/v1/billing/status` (staging routes to the isolated
129
+ * staging DataEdge, production to the prod DataEdge). Chain-aware clients MUST
130
+ * consume it verbatim — otherwise writes mine on the WASM-baked prod DataEdge
131
+ * while reads hit the relay's subgraph, so recall silently returns empty
132
+ * against staging (#460). Mirrors `_chainIdOverride`.
133
+ *
134
+ * `null` means "no billing override" → fall through to the WASM default. The
135
+ * explicit operator env override (`TOTALRECLAW_DATA_EDGE_ADDRESS`, exposed as
136
+ * `CONFIG.dataEdgeAddress`) always wins over this.
137
+ */
138
+ let _dataEdgeAddressOverride: string | null = null;
139
+
140
+ export function setDataEdgeAddressOverride(address: string | null): void {
141
+ _dataEdgeAddressOverride = address;
142
+ }
143
+
144
+ /**
145
+ * Read the billing-sourced DataEdge override (or `''` when unset). Used by
146
+ * `getSubgraphConfig` as the middle term of the env → billing → WASM-default
147
+ * resolution order (#460).
148
+ */
149
+ export function getDataEdgeAddressOverride(): string {
150
+ return _dataEdgeAddressOverride ?? '';
151
+ }
152
+
153
+ /** Reset the DataEdge override — used by tests. */
154
+ export function __resetDataEdgeAddressOverrideForTests(): void {
155
+ _dataEdgeAddressOverride = null;
156
+ }
157
+
125
158
  export const CONFIG = {
126
159
  // Core — recoveryPhrase reads from override first, then env var.
127
160
  // Use getRecoveryPhrase() for dynamic access; this property is for
@@ -148,6 +181,42 @@ export const CONFIG = {
148
181
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
149
182
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
150
183
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
184
+ // cred-3 stage 1 — credential-provider abstraction.
185
+ //
186
+ // `file` (default) preserves the legacy behavior: read/write
187
+ // `credentialsPath` directly via fs-helpers.
188
+ //
189
+ // `external` instructs the plugin to load credentials from an
190
+ // out-of-process source at boot. Two transports are supported (mutually
191
+ // exclusive — JSON wins if both set):
192
+ // 1. `externalCredentialsJson` — raw JSON string of CredentialsFile,
193
+ // injected at process start by the secret manager (Railway secrets,
194
+ // Docker `--env-file`, K8s envFrom). Most ergonomic for cloud
195
+ // deployments where the secret manager already exposes secrets as
196
+ // env vars.
197
+ // 2. `externalCredentialsPath` — filesystem path to a JSON file the
198
+ // secret manager mounts (Docker Compose `secrets:` block,
199
+ // K8s secret volumeMount, tmpfs populated by an ops wrapper that
200
+ // fetches from AWS Secrets Manager / Hetzner Vault before boot).
201
+ //
202
+ // `external` mode is read-only: write/clear are no-ops with a warning
203
+ // (the secret manager owns the source of truth — the plugin must not
204
+ // write back through this path).
205
+ //
206
+ // See `docs/guides/production-deployment.md` for the LUKS / dm-crypt
207
+ // pattern and managed-secret-store wiring examples.
208
+ credentialsProvider: (() => {
209
+ const v = (process.env.TOTALRECLAW_CREDENTIALS_PROVIDER ?? '').trim().toLowerCase();
210
+ return v === 'external' ? 'external' : 'file';
211
+ })() as 'file' | 'external',
212
+ externalCredentialsJson: (() => {
213
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_JSON ?? '').trim();
214
+ return v.length > 0 ? v : null;
215
+ })() as string | null,
216
+ externalCredentialsPath: (() => {
217
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_PATH ?? '').trim();
218
+ return v.length > 0 ? v : null;
219
+ })() as string | null,
151
220
  // 3.2.0 onboarding state file — separate from credentials.json so it
152
221
  // never contains secrets. Loaded on every plugin init + on every
153
222
  // before_tool_call gate check.
@@ -185,21 +254,28 @@ export const CONFIG = {
185
254
  : 'wss://api.totalreclaw.xyz')
186
255
  ).replace(/\/+$/, ''),
187
256
 
188
- // Chain — chainId is no longer user-configurable. It is auto-detected from
189
- // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
190
- // 100). The default here is used only before the first billing lookup
257
+ // Chain — chainId is no longer user-configurable. After the ops-1 single-
258
+ // chain migration, ALL tiers are on Gnosis (100). The default here is 100.
191
259
  // completes. Self-hosted users can still point at a custom DataEdge via
192
260
  // TOTALRECLAW_DATA_EDGE_ADDRESS / TOTALRECLAW_ENTRYPOINT_ADDRESS /
193
261
  // TOTALRECLAW_RPC_URL (undocumented; internal knobs).
194
262
  //
195
- // Reads the runtime override set by the billing auto-detect in index.ts.
196
- // Falls back to 84532 (free tier / pre-billing-lookup). Must be a getter,
197
- // not a literal a literal would freeze all Pro-tier UserOps to the
198
- // wrong chainId and AA23 at the bundler.
263
+ // Reads the runtime override set from the relay's authoritative chain_id
264
+ // (syncChainIdFromBilling). Falls back to 100 (Gnosis) pre-billing-lookup
265
+ // after ops-1 both tiers are on Gnosis, so 84532 is never the default (#402).
266
+ // Must be a getter, not a literal — a literal would freeze UserOps to the
267
+ // wrong chainId and fail signature validation at the bundler.
199
268
  get chainId(): number {
200
- return _chainIdOverride ?? 84532;
269
+ return _chainIdOverride ?? 100;
270
+ },
271
+ // Explicit operator env override for the DataEdge contract. Stays env-only:
272
+ // it is the FIRST term of `getSubgraphConfig`'s env → billing → WASM-default
273
+ // resolution order, so it wins over the relay's authoritative
274
+ // `data_edge_address` (see `getDataEdgeAddressOverride` / #460). Getter, not
275
+ // a literal, so it reflects the live env rather than freezing at module load.
276
+ get dataEdgeAddress(): string {
277
+ return process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '';
201
278
  },
202
- dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '',
203
279
  entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || '',
204
280
  rpcUrl: process.env.TOTALRECLAW_RPC_URL || '',
205
281
 
@@ -224,6 +300,12 @@ export const CONFIG = {
224
300
  // See: docs/specs/totalreclaw/client-consistency.md
225
301
  cosineThreshold: parseFloat(process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15'),
226
302
  extractInterval: parseInt(process.env.TOTALRECLAW_EXTRACT_INTERVAL ?? process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '3', 10),
303
+ // Self-hosted fallback for max-facts-per-extraction. `undefined` when the
304
+ // env var is unset so getMaxFactsPerExtraction() can fall through to the
305
+ // billing cache then the built-in MAX_FACTS_PER_EXTRACTION constant.
306
+ maxFactsPerExtraction: process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION
307
+ ? parseInt(process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION, 10)
308
+ : undefined,
227
309
  relevanceThreshold: parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3'),
228
310
  semanticSkipThreshold: parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85'),
229
311
  cacheTtlMs: parseInt(process.env.TOTALRECLAW_CACHE_TTL_MS ?? String(5 * 60 * 1000), 10),
package/consolidation.ts CHANGED
@@ -18,11 +18,14 @@
18
18
  * and `clusterFacts` WASM functions when available, falling back to local
19
19
  * implementations that use WASM-backed `cosineSimilarity`.
20
20
  *
21
- * Threshold helpers remain local (they read process.env).
21
+ * Threshold helpers remain local; their env reads are centralized in
22
+ * entry.ts (env-reading seam, Task 1.3 of the OpenClaw native
23
+ * integration plan, 2026-06-21).
22
24
  */
23
25
 
24
26
  import { createRequire } from 'node:module';
25
27
  import { cosineSimilarity } from './reranker.js';
28
+ import { envNumber } from './entry.js';
26
29
 
27
30
  // ---------------------------------------------------------------------------
28
31
  // Lazy-load WASM core (mirrors claims-helper.ts / contradiction-sync.ts
@@ -47,12 +50,7 @@ function getWasm(): typeof import('@totalreclaw/core') {
47
50
  * Must be a number in [0, 1]. Falls back to 0.85 if invalid or unset.
48
51
  */
49
52
  export function getStoreDedupThreshold(): number {
50
- const envVal = process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
51
- if (envVal !== undefined) {
52
- const parsed = parseFloat(envVal);
53
- if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) return parsed;
54
- }
55
- return 0.85;
53
+ return envNumber('TOTALRECLAW_STORE_DEDUP_THRESHOLD', 0.85, { min: 0, max: 1 });
56
54
  }
57
55
 
58
56
  /**
@@ -62,12 +60,7 @@ export function getStoreDedupThreshold(): number {
62
60
  * Must be a number in [0, 1]. Falls back to 0.88 if invalid or unset.
63
61
  */
64
62
  export function getConsolidationThreshold(): number {
65
- const envVal = process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
66
- if (envVal !== undefined) {
67
- const parsed = parseFloat(envVal);
68
- if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) return parsed;
69
- }
70
- return 0.88;
63
+ return envNumber('TOTALRECLAW_CONSOLIDATION_THRESHOLD', 0.88, { min: 0, max: 1 });
71
64
  }
72
65
 
73
66
  /** Maximum candidates to compare against during store-time dedup. */