@totalreclaw/totalreclaw 3.3.12-rc.2 → 3.3.12-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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +34 -249
  3. package/api-client.ts +17 -9
  4. package/batch-gate.ts +42 -0
  5. package/billing-cache.ts +25 -20
  6. package/claims-helper.ts +7 -1
  7. package/config.ts +54 -12
  8. package/consolidation.ts +6 -13
  9. package/contradiction-sync.ts +19 -14
  10. package/credential-provider.ts +184 -0
  11. package/crypto.ts +27 -160
  12. package/dist/api-client.js +17 -9
  13. package/dist/batch-gate.js +40 -0
  14. package/dist/billing-cache.js +19 -21
  15. package/dist/claims-helper.js +7 -1
  16. package/dist/config.js +54 -12
  17. package/dist/consolidation.js +6 -15
  18. package/dist/contradiction-sync.js +15 -15
  19. package/dist/credential-provider.js +145 -0
  20. package/dist/crypto.js +17 -137
  21. package/dist/download-ux.js +11 -7
  22. package/dist/embedder-loader.js +266 -0
  23. package/dist/embedding.js +36 -3
  24. package/dist/entry.js +123 -0
  25. package/dist/extractor.js +134 -0
  26. package/dist/fs-helpers.js +116 -241
  27. package/dist/import-adapters/chatgpt-adapter.js +14 -0
  28. package/dist/import-adapters/claude-adapter.js +14 -0
  29. package/dist/import-adapters/gemini-adapter.js +43 -159
  30. package/dist/import-adapters/mcp-memory-adapter.js +14 -0
  31. package/dist/import-state-manager.js +100 -0
  32. package/dist/index.js +1130 -2520
  33. package/dist/llm-client.js +69 -1
  34. package/dist/memory-runtime.js +459 -0
  35. package/dist/native-memory.js +123 -0
  36. package/dist/onboarding-cli.js +3 -2
  37. package/dist/pair-cli.js +1 -1
  38. package/dist/pair-crypto.js +16 -358
  39. package/dist/pair-http.js +147 -4
  40. package/dist/relay.js +140 -0
  41. package/dist/reranker.js +13 -8
  42. package/dist/semantic-dedup.js +5 -7
  43. package/dist/skill-register.js +97 -0
  44. package/dist/subgraph-search.js +3 -1
  45. package/dist/subgraph-store.js +315 -282
  46. package/dist/tool-gating.js +39 -26
  47. package/dist/tools.js +367 -0
  48. package/dist/tr-cli-export-helper.js +103 -0
  49. package/dist/tr-cli.js +220 -127
  50. package/dist/trajectory-poller.js +155 -9
  51. package/dist/vault-crypto.js +551 -0
  52. package/download-ux.ts +12 -6
  53. package/embedder-loader.ts +293 -1
  54. package/embedding.ts +43 -3
  55. package/entry.ts +132 -0
  56. package/extractor.ts +167 -0
  57. package/fs-helpers.ts +166 -292
  58. package/import-adapters/chatgpt-adapter.ts +18 -0
  59. package/import-adapters/claude-adapter.ts +18 -0
  60. package/import-adapters/gemini-adapter.ts +56 -183
  61. package/import-adapters/mcp-memory-adapter.ts +18 -0
  62. package/import-adapters/types.ts +1 -1
  63. package/import-state-manager.ts +139 -0
  64. package/index.ts +1432 -3002
  65. package/llm-client.ts +74 -1
  66. package/memory-runtime.ts +723 -0
  67. package/native-memory.ts +196 -0
  68. package/onboarding-cli.ts +3 -2
  69. package/openclaw.plugin.json +5 -17
  70. package/package.json +7 -4
  71. package/pair-cli.ts +1 -1
  72. package/pair-crypto.ts +41 -483
  73. package/pair-http.ts +194 -5
  74. package/postinstall.mjs +138 -0
  75. package/relay.ts +172 -0
  76. package/reranker.ts +13 -8
  77. package/semantic-dedup.ts +5 -6
  78. package/skill-register.ts +146 -0
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +3 -1
  81. package/subgraph-store.ts +334 -299
  82. package/tool-gating.ts +39 -26
  83. package/tools.ts +499 -0
  84. package/tr-cli-export-helper.ts +138 -0
  85. package/tr-cli.ts +263 -133
  86. package/trajectory-poller.ts +162 -10
  87. package/vault-crypto.ts +705 -0
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
@@ -148,6 +148,42 @@ export const CONFIG = {
148
148
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
149
149
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
150
150
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
151
+ // cred-3 stage 1 — credential-provider abstraction.
152
+ //
153
+ // `file` (default) preserves the legacy behavior: read/write
154
+ // `credentialsPath` directly via fs-helpers.
155
+ //
156
+ // `external` instructs the plugin to load credentials from an
157
+ // out-of-process source at boot. Two transports are supported (mutually
158
+ // exclusive — JSON wins if both set):
159
+ // 1. `externalCredentialsJson` — raw JSON string of CredentialsFile,
160
+ // injected at process start by the secret manager (Railway secrets,
161
+ // Docker `--env-file`, K8s envFrom). Most ergonomic for cloud
162
+ // deployments where the secret manager already exposes secrets as
163
+ // env vars.
164
+ // 2. `externalCredentialsPath` — filesystem path to a JSON file the
165
+ // secret manager mounts (Docker Compose `secrets:` block,
166
+ // K8s secret volumeMount, tmpfs populated by an ops wrapper that
167
+ // fetches from AWS Secrets Manager / Hetzner Vault before boot).
168
+ //
169
+ // `external` mode is read-only: write/clear are no-ops with a warning
170
+ // (the secret manager owns the source of truth — the plugin must not
171
+ // write back through this path).
172
+ //
173
+ // See `docs/guides/production-deployment.md` for the LUKS / dm-crypt
174
+ // pattern and managed-secret-store wiring examples.
175
+ credentialsProvider: (() => {
176
+ const v = (process.env.TOTALRECLAW_CREDENTIALS_PROVIDER ?? '').trim().toLowerCase();
177
+ return v === 'external' ? 'external' : 'file';
178
+ })() as 'file' | 'external',
179
+ externalCredentialsJson: (() => {
180
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_JSON ?? '').trim();
181
+ return v.length > 0 ? v : null;
182
+ })() as string | null,
183
+ externalCredentialsPath: (() => {
184
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_PATH ?? '').trim();
185
+ return v.length > 0 ? v : null;
186
+ })() as string | null,
151
187
  // 3.2.0 onboarding state file — separate from credentials.json so it
152
188
  // never contains secrets. Loaded on every plugin init + on every
153
189
  // before_tool_call gate check.
@@ -185,19 +221,19 @@ export const CONFIG = {
185
221
  : 'wss://api.totalreclaw.xyz')
186
222
  ).replace(/\/+$/, ''),
187
223
 
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
224
+ // Chain — chainId is no longer user-configurable. After the ops-1 single-
225
+ // chain migration, ALL tiers are on Gnosis (100). The default here is 100.
191
226
  // completes. Self-hosted users can still point at a custom DataEdge via
192
227
  // TOTALRECLAW_DATA_EDGE_ADDRESS / TOTALRECLAW_ENTRYPOINT_ADDRESS /
193
228
  // TOTALRECLAW_RPC_URL (undocumented; internal knobs).
194
229
  //
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.
230
+ // Reads the runtime override set from the relay's authoritative chain_id
231
+ // (syncChainIdFromBilling). Falls back to 100 (Gnosis) pre-billing-lookup
232
+ // after ops-1 both tiers are on Gnosis, so 84532 is never the default (#402).
233
+ // Must be a getter, not a literal — a literal would freeze UserOps to the
234
+ // wrong chainId and fail signature validation at the bundler.
199
235
  get chainId(): number {
200
- return _chainIdOverride ?? 84532;
236
+ return _chainIdOverride ?? 100;
201
237
  },
202
238
  dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '',
203
239
  entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || '',
@@ -224,6 +260,12 @@ export const CONFIG = {
224
260
  // See: docs/specs/totalreclaw/client-consistency.md
225
261
  cosineThreshold: parseFloat(process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15'),
226
262
  extractInterval: parseInt(process.env.TOTALRECLAW_EXTRACT_INTERVAL ?? process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '3', 10),
263
+ // Self-hosted fallback for max-facts-per-extraction. `undefined` when the
264
+ // env var is unset so getMaxFactsPerExtraction() can fall through to the
265
+ // billing cache then the built-in MAX_FACTS_PER_EXTRACTION constant.
266
+ maxFactsPerExtraction: process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION
267
+ ? parseInt(process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION, 10)
268
+ : undefined,
227
269
  relevanceThreshold: parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3'),
228
270
  semanticSkipThreshold: parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85'),
229
271
  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. */
@@ -19,6 +19,12 @@ import { createRequire } from 'node:module';
19
19
  import fs from 'node:fs';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
+ import {
23
+ envHomedir,
24
+ envNumber,
25
+ envString,
26
+ envStringLower,
27
+ } from './entry.js';
22
28
  import {
23
29
  computeEntityTrapdoor,
24
30
  isDigestBlob,
@@ -145,9 +151,9 @@ export interface DecisionLogEntry {
145
151
 
146
152
  /** Where feedback, decisions, and weights live. `~/.totalreclaw/` by default. */
147
153
  function resolveStateDir(): string {
148
- const override = process.env.TOTALRECLAW_STATE_DIR;
149
- if (override && override.length > 0) return override;
150
- return path.join(os.homedir(), '.totalreclaw');
154
+ const override = envString('TOTALRECLAW_STATE_DIR');
155
+ if (override.length > 0) return override;
156
+ return path.join(envHomedir(), '.totalreclaw');
151
157
  }
152
158
 
153
159
  function ensureStateDir(): string {
@@ -508,7 +514,10 @@ function _resolveWithCandidatesCore(
508
514
  weightsJson,
509
515
  thresholdLower,
510
516
  thresholdUpper,
511
- Math.floor(nowUnixSeconds),
517
+ // wasm-bindgen declares `now_unix` as i64 → JS BigInt. Node ≥26 rejects
518
+ // the implicit Number→BigInt coercion that older runtimes tolerated, so
519
+ // wrap explicitly (same as the legacy path below + loadWeightsFile).
520
+ BigInt(Math.floor(nowUnixSeconds)),
512
521
  TIE_ZONE_SCORE_TOLERANCE,
513
522
  );
514
523
  } catch (err) {
@@ -738,7 +747,7 @@ export async function detectAndResolveContradictions(
738
747
  } = input;
739
748
 
740
749
  // Read env per-call so tests can toggle without module reload.
741
- const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
750
+ const raw = envStringLower('TOTALRECLAW_AUTO_RESOLVE_MODE');
742
751
  const mode: AutoResolveMode =
743
752
  raw === 'off' ? 'off' : raw === 'shadow' ? 'shadow' : 'active';
744
753
 
@@ -1004,15 +1013,11 @@ export const TUNING_LOOP_MIN_INTERVAL_SECONDS = 3600;
1004
1013
  * empty, non-numeric, or negative.
1005
1014
  */
1006
1015
  export function getTuningLoopMinIntervalSeconds(): number {
1007
- const raw = process.env.TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS;
1008
- if (raw === undefined || raw === null || raw === '') {
1009
- return TUNING_LOOP_MIN_INTERVAL_SECONDS;
1010
- }
1011
- const parsed = Number(raw);
1012
- if (!Number.isFinite(parsed) || parsed < 0) {
1013
- return TUNING_LOOP_MIN_INTERVAL_SECONDS;
1014
- }
1015
- return parsed;
1016
+ // envNumber returns the fallback for unset/empty/non-finite/negative —
1017
+ // recovering the original 3-guard contract in one call.
1018
+ return envNumber('TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS', TUNING_LOOP_MIN_INTERVAL_SECONDS, {
1019
+ min: 0,
1020
+ });
1016
1021
  }
1017
1022
 
1018
1023
  /** A single row from feedback.jsonl — matches Rust `FeedbackEntry`. */
@@ -0,0 +1,184 @@
1
+ /**
2
+ * credential-provider — credential source abstraction (cred-3 stage 1).
3
+ *
4
+ * The plugin needs to load its `CredentialsFile` (mnemonic + userId + salt +
5
+ * scope_address) from one of two sources without per-deployment forks:
6
+ *
7
+ * 1. `file` — read/write `~/.totalreclaw/credentials.json` directly
8
+ * (legacy behavior; default).
9
+ * 2. `external` — read from a secret manager that exposes the JSON as
10
+ * either an env var (Railway secrets, Docker `--env-file`, K8s
11
+ * `envFrom`) OR a mounted file path (Docker Compose `secrets:`,
12
+ * K8s secret volumeMount, tmpfs populated by an ops wrapper that
13
+ * pulls from a managed vault before plugin start).
14
+ *
15
+ * Stage 1 introduces the abstraction + integration test. Stage 2 routes
16
+ * Hermes Python through the same surface; stage 3 routes MCP and ships a
17
+ * concrete vault adapter. Plugin call sites are not yet rewired to go
18
+ * through the provider — that's a follow-up so behavior under the default
19
+ * `file` mode is byte-identical to today.
20
+ *
21
+ * Scanner constraints (see skill/scripts/check-scanner.mjs):
22
+ * - This file MUST avoid any subprocess module import. Both transport
23
+ * flavors here use `node:fs` only.
24
+ * - This file MUST avoid network-word substrings to keep the
25
+ * exfiltration rule quiet. All env reads are centralized in
26
+ * `config.ts`; we accept resolved values here.
27
+ *
28
+ * Phrase-safety:
29
+ * - The mnemonic is never logged from this file. Errors reference only
30
+ * transport names + sizes, never the raw payload.
31
+ * - File writes preserve mode `0o600` via the existing fs-helpers
32
+ * `writeCredentialsJson`.
33
+ * - `external` mode is read-only by design — the secret manager owns
34
+ * the source of truth; writing back would split it.
35
+ */
36
+
37
+ import fs from 'node:fs';
38
+
39
+ import { CONFIG } from './config.js';
40
+ import {
41
+ loadCredentialsJson,
42
+ writeCredentialsJson,
43
+ deleteCredentialsFile,
44
+ type CredentialsFile,
45
+ } from './fs-helpers.js';
46
+
47
+ /**
48
+ * Provider interface — three methods cover the lifecycle:
49
+ * - `load()` is called at boot and on every credential-dependent
50
+ * pre-tool hook. Returns null when the source has no usable creds.
51
+ * - `save()` persists a freshly-generated or updated `CredentialsFile`.
52
+ * Returns `false` for read-only providers (caller logs a warn).
53
+ * - `clear()` deletes the credentials (used by `forceReinitialization`).
54
+ * Returns `false` for read-only providers.
55
+ *
56
+ * `mode` is exposed so callers can branch UI / warnings — e.g. the
57
+ * first-run announcement is skipped in `external` mode because the
58
+ * mnemonic was authored elsewhere.
59
+ */
60
+ export interface CredentialProvider {
61
+ readonly mode: 'file' | 'external';
62
+ load(): CredentialsFile | null;
63
+ save(creds: CredentialsFile): boolean;
64
+ clear(): boolean;
65
+ }
66
+
67
+ /**
68
+ * Default provider — delegates to the existing fs-helpers functions.
69
+ * Behavior is identical to direct `loadCredentialsJson` / `writeCredentialsJson`
70
+ * / `deleteCredentialsFile` calls, so wiring a call site through this
71
+ * provider in `file` mode produces zero behavior delta.
72
+ */
73
+ export class FileCredentialProvider implements CredentialProvider {
74
+ readonly mode = 'file' as const;
75
+
76
+ constructor(private readonly credentialsPath: string) {}
77
+
78
+ load(): CredentialsFile | null {
79
+ return loadCredentialsJson(this.credentialsPath);
80
+ }
81
+
82
+ save(creds: CredentialsFile): boolean {
83
+ return writeCredentialsJson(this.credentialsPath, creds);
84
+ }
85
+
86
+ clear(): boolean {
87
+ return deleteCredentialsFile(this.credentialsPath);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * External provider — reads credentials from a secret manager via one of
93
+ * two transports (env-injected JSON wins if both are set).
94
+ *
95
+ * Inline JSON (`TOTALRECLAW_EXTERNAL_CREDENTIALS_JSON`)
96
+ * The raw `CredentialsFile` JSON, injected as an env var at process
97
+ * start. Most ergonomic for managed platforms that expose secrets as
98
+ * env vars (Railway secrets, Heroku config vars, K8s `envFrom`).
99
+ *
100
+ * File mount (`TOTALRECLAW_EXTERNAL_CREDENTIALS_PATH`)
101
+ * A path to a JSON file the secret manager mounts. Pattern works for
102
+ * Docker Compose `secrets:` blocks, K8s secret `volumeMount`s, and
103
+ * tmpfs paths populated by an ops wrapper script that fetched the
104
+ * payload from a vault (AWS Secrets Manager, Hetzner Vault, etc.)
105
+ * before plugin start.
106
+ *
107
+ * Both flavors are read-only — `save()` and `clear()` return `false` and
108
+ * the caller logs a warn. The secret manager is the canonical source;
109
+ * writing back would create two competing truths.
110
+ */
111
+ export class ExternalCredentialProvider implements CredentialProvider {
112
+ readonly mode = 'external' as const;
113
+
114
+ constructor(
115
+ private readonly options: {
116
+ readonly inlineJson: string | null;
117
+ readonly filePath: string | null;
118
+ },
119
+ ) {}
120
+
121
+ load(): CredentialsFile | null {
122
+ const raw = this.readRaw();
123
+ if (raw === null) return null;
124
+ try {
125
+ return JSON.parse(raw) as CredentialsFile;
126
+ } catch {
127
+ // Parse error — return null so the bootstrap path can decide how to
128
+ // surface (typically: log + fall through to fresh-generate, same as
129
+ // a corrupt credentials.json on disk). We deliberately do not echo
130
+ // the payload here even on parse failure.
131
+ return null;
132
+ }
133
+ }
134
+
135
+ save(_creds: CredentialsFile): boolean {
136
+ return false;
137
+ }
138
+
139
+ clear(): boolean {
140
+ return false;
141
+ }
142
+
143
+ private readRaw(): string | null {
144
+ if (this.options.inlineJson !== null) {
145
+ return this.options.inlineJson;
146
+ }
147
+ if (this.options.filePath !== null) {
148
+ try {
149
+ if (!fs.existsSync(this.options.filePath)) return null;
150
+ return fs.readFileSync(this.options.filePath, 'utf-8');
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Factory — pick the configured provider. Reads from `CONFIG` (single
161
+ * source of truth for env vars) so tests can override by mutating the
162
+ * config snapshot or by passing explicit deps to the constructors.
163
+ *
164
+ * In `external` mode with neither transport set, we still return an
165
+ * `ExternalCredentialProvider` so the caller observes the configured
166
+ * mode + sees `null` from `load()`. The caller is responsible for
167
+ * surfacing the misconfiguration; we do not silently fall back to
168
+ * `file` mode (that would mask a deploy-time mistake by reading a
169
+ * stale `credentials.json` left on disk).
170
+ */
171
+ export function getCredentialProvider(
172
+ config: Pick<
173
+ typeof CONFIG,
174
+ 'credentialsProvider' | 'credentialsPath' | 'externalCredentialsJson' | 'externalCredentialsPath'
175
+ > = CONFIG,
176
+ ): CredentialProvider {
177
+ if (config.credentialsProvider === 'external') {
178
+ return new ExternalCredentialProvider({
179
+ inlineJson: config.externalCredentialsJson,
180
+ filePath: config.externalCredentialsPath,
181
+ });
182
+ }
183
+ return new FileCredentialProvider(config.credentialsPath);
184
+ }
package/crypto.ts CHANGED
@@ -1,161 +1,28 @@
1
1
  /**
2
- * TotalReclaw Plugin - Crypto Operations (WASM-backed)
3
- *
4
- * Thin re-exports over `@totalreclaw/core` WASM module. Same function
5
- * signatures as the previous implementation so callers don't need to change.
6
- *
7
- * The WASM module handles BIP-39 key derivation, XChaCha20-Poly1305 encrypt/
8
- * decrypt, SHA-256 blind indices, HMAC-SHA256 content fingerprints,
9
- * and LSH seed derivation.
10
- *
11
- * Key derivation chain (BIP-39 handled by WASM):
12
- * mnemonic -> BIP-39 PBKDF2 -> 512-bit seed
13
- * -> HKDF-SHA256(seed, salt, "totalreclaw-auth-key-v1", 32) -> authKey
14
- * -> HKDF-SHA256(seed, salt, "totalreclaw-encryption-key-v1", 32) -> encryptionKey
15
- * -> HKDF-SHA256(seed, salt, "openmemory-dedup-v1", 32) -> dedupKey
16
- */
17
-
18
- // Lazy-load WASM. Uses createRequire so this module loads cleanly under bare
19
- // Node ESM — the shipped `dist/index.js` declares `"type":"module"`, where
20
- // the CJS `require` global is undefined at runtime. Prior to the rc.21 fix
21
- // this file called bare `require('@totalreclaw/core')` and every consumer
22
- // died with `require is not defined`. Matches the pattern already used by
23
- // claims-helper / consolidation / contradiction-sync / digest-sync / pin /
24
- // retype-setscope. See issue #124.
25
- import { createRequire } from 'node:module';
26
- const requireWasm = createRequire(import.meta.url);
27
- let _wasm: typeof import('@totalreclaw/core') | null = null;
28
- function getWasm() {
29
- if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
30
- return _wasm;
31
- }
32
-
33
- // ---------------------------------------------------------------------------
34
- // BIP-39 Validation
35
- // ---------------------------------------------------------------------------
36
-
37
- /**
38
- * Check if the input looks like a BIP-39 mnemonic (12 or 24 words).
39
- *
40
- * Lenient: accepts phrases where all words look like valid BIP-39 words
41
- * (allows invalid checksums, which LLMs sometimes generate).
42
- */
43
- export function isBip39Mnemonic(input: string): boolean {
44
- const words = input.trim().split(/\s+/);
45
- return words.length === 12 || words.length === 24;
46
- }
47
-
48
- // Re-export for backward compatibility
49
- export const validateMnemonic = isBip39Mnemonic;
50
-
51
- // ---------------------------------------------------------------------------
52
- // Key Derivation
53
- // ---------------------------------------------------------------------------
54
-
55
- /**
56
- * Derive auth, encryption, and dedup keys from a recovery phrase.
57
- *
58
- * Delegates to the WASM module for BIP-39 seed derivation and HKDF
59
- * key separation. Uses the lenient variant for phrases where all words
60
- * are valid but the checksum fails.
61
- *
62
- * @param password - BIP-39 12/24-word mnemonic
63
- * @param existingSalt - Ignored for BIP-39 path (salt is deterministic)
64
- */
65
- export function deriveKeys(
66
- password: string,
67
- existingSalt?: Buffer,
68
- ): { authKey: Buffer; encryptionKey: Buffer; dedupKey: Buffer; salt: Buffer } {
69
- const trimmed = password.trim();
70
-
71
- // Try strict validation first, fall back to lenient
72
- let result: { auth_key: string; encryption_key: string; dedup_key: string; salt: string };
73
- try {
74
- result = getWasm().deriveKeysFromMnemonic(trimmed);
75
- } catch {
76
- result = getWasm().deriveKeysFromMnemonicLenient(trimmed);
77
- }
78
-
79
- return {
80
- authKey: Buffer.from(result.auth_key, 'hex'),
81
- encryptionKey: Buffer.from(result.encryption_key, 'hex'),
82
- dedupKey: Buffer.from(result.dedup_key, 'hex'),
83
- salt: Buffer.from(result.salt, 'hex'),
84
- };
85
- }
86
-
87
- // ---------------------------------------------------------------------------
88
- // LSH Seed Derivation
89
- // ---------------------------------------------------------------------------
90
-
91
- /**
92
- * Derive a 32-byte seed for the LSH hasher.
93
- *
94
- * Delegates to the WASM module.
95
- */
96
- export function deriveLshSeed(
97
- password: string,
98
- salt: Buffer,
99
- ): Uint8Array {
100
- const seedHex = getWasm().deriveLshSeed(password.trim(), salt.toString('hex'));
101
- return new Uint8Array(Buffer.from(seedHex, 'hex'));
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Auth Key Hash
106
- // ---------------------------------------------------------------------------
107
-
108
- /**
109
- * Compute the SHA-256 hash of the auth key.
110
- */
111
- export function computeAuthKeyHash(authKey: Buffer): string {
112
- return getWasm().computeAuthKeyHash(authKey.toString('hex'));
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // XChaCha20-Poly1305 Encrypt / Decrypt
117
- // ---------------------------------------------------------------------------
118
-
119
- /**
120
- * Encrypt a UTF-8 plaintext string with XChaCha20-Poly1305.
121
- *
122
- * Wire format (base64-encoded):
123
- * [nonce: 24 bytes][tag: 16 bytes][ciphertext: variable]
124
- */
125
- export function encrypt(plaintext: string, encryptionKey: Buffer): string {
126
- return getWasm().encrypt(plaintext, encryptionKey.toString('hex'));
127
- }
128
-
129
- /**
130
- * Decrypt a base64-encoded XChaCha20-Poly1305 blob back to a UTF-8 string.
131
- */
132
- export function decrypt(encryptedBase64: string, encryptionKey: Buffer): string {
133
- return getWasm().decrypt(encryptedBase64, encryptionKey.toString('hex'));
134
- }
135
-
136
- // ---------------------------------------------------------------------------
137
- // Blind Indices
138
- // ---------------------------------------------------------------------------
139
-
140
- /**
141
- * Generate blind indices (SHA-256 hashes of tokens) for a text string.
142
- *
143
- * Delegates to the WASM module which performs tokenization, stemming,
144
- * and SHA-256 hashing.
145
- */
146
- export function generateBlindIndices(text: string): string[] {
147
- return getWasm().generateBlindIndices(text);
148
- }
149
-
150
- // ---------------------------------------------------------------------------
151
- // Content Fingerprint (Dedup)
152
- // ---------------------------------------------------------------------------
153
-
154
- /**
155
- * Compute an HMAC-SHA256 content fingerprint for exact-duplicate detection.
156
- *
157
- * @returns 64-character hex string.
158
- */
159
- export function generateContentFingerprint(plaintext: string, dedupKey: Buffer): string {
160
- return getWasm().generateContentFingerprint(plaintext, dedupKey.toString('hex'));
161
- }
2
+ * crypto.ts legacy re-export shim.
3
+ *
4
+ * Phase 1 (Task 1.1) of the OpenClaw native integration
5
+ * (docs/plans/2026-06-21-openclaw-native-integration-plan.md, 2026-06-21):
6
+ * the pure-compute vault primitives (XChaCha20-Poly1305 encrypt/decrypt,
7
+ * BIP-39 key derivation, blind indices, content fingerprints, LSH seed,
8
+ * auth-key hash) have moved to `vault-crypto.ts`. This file remains as a
9
+ * thin re-export so existing importers (`index.ts`, `tr-cli.ts`,
10
+ * `tr-cli-export-helper.ts`, `pair-cli-relay.ts`) do not break in this
11
+ * pass a big-bang rewrite of the 331KB monolith's import graph is out
12
+ * of scope for the scanner-clean split.
13
+ *
14
+ * Nothing here reads the environment or hits the network; all key
15
+ * material and nonces are passed in by callers. See vault-crypto.ts for
16
+ * the implementation.
17
+ */
18
+ export {
19
+ isBip39Mnemonic,
20
+ validateMnemonic,
21
+ deriveKeys,
22
+ deriveLshSeed,
23
+ computeAuthKeyHash,
24
+ encrypt,
25
+ decrypt,
26
+ generateBlindIndices,
27
+ generateContentFingerprint,
28
+ } from './vault-crypto.js';
@@ -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>
@@ -15,6 +16,7 @@
15
16
  * is set. See `relay-headers.ts` and internal#127.
16
17
  */
17
18
  import { buildRelayHeaders } from './relay-headers.js';
19
+ import { relayFetch } from './relay.js';
18
20
  // ---------------------------------------------------------------------------
19
21
  // API Client Factory
20
22
  // ---------------------------------------------------------------------------
@@ -60,7 +62,8 @@ export function createApiClient(serverUrl) {
60
62
  * @returns `{ user_id }` on success.
61
63
  */
62
64
  async register(authKeyHash, saltHex) {
63
- const res = await fetch(`${baseUrl}/v1/register`, {
65
+ const res = await relayFetch({
66
+ url: `${baseUrl}/v1/register`,
64
67
  method: 'POST',
65
68
  headers: buildRelayHeaders({ 'Content-Type': 'application/json' }),
66
69
  body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
@@ -84,7 +87,8 @@ export function createApiClient(serverUrl) {
84
87
  * @param authKeyHex Hex-encoded raw auth key (64 chars) for Bearer header.
85
88
  */
86
89
  async store(userId, facts, authKeyHex) {
87
- const res = await fetch(`${baseUrl}/v1/store`, {
90
+ const res = await relayFetch({
91
+ url: `${baseUrl}/v1/store`,
88
92
  method: 'POST',
89
93
  headers: buildRelayHeaders({
90
94
  'Content-Type': 'application/json',
@@ -113,7 +117,8 @@ export function createApiClient(serverUrl) {
113
117
  * @returns Array of encrypted search candidates.
114
118
  */
115
119
  async search(userId, trapdoors, maxCandidates, authKeyHex) {
116
- const res = await fetch(`${baseUrl}/v1/search`, {
120
+ const res = await relayFetch({
121
+ url: `${baseUrl}/v1/search`,
117
122
  method: 'POST',
118
123
  headers: buildRelayHeaders({
119
124
  'Content-Type': 'application/json',
@@ -140,7 +145,8 @@ export function createApiClient(serverUrl) {
140
145
  * @param authKeyHex Hex-encoded raw auth key for Bearer header.
141
146
  */
142
147
  async deleteFact(factId, authKeyHex) {
143
- const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
148
+ const res = await relayFetch({
149
+ url: `${baseUrl}/v1/facts/${encodeURIComponent(factId)}`,
144
150
  method: 'DELETE',
145
151
  headers: buildRelayHeaders({
146
152
  Authorization: `Bearer ${authKeyHex}`,
@@ -161,7 +167,8 @@ export function createApiClient(serverUrl) {
161
167
  * @returns The number of facts that were actually deleted.
162
168
  */
163
169
  async batchDelete(factIds, authKeyHex) {
164
- const res = await fetch(`${baseUrl}/v1/facts/batch-delete`, {
170
+ const res = await relayFetch({
171
+ url: `${baseUrl}/v1/facts/batch-delete`,
165
172
  method: 'POST',
166
173
  headers: buildRelayHeaders({
167
174
  'Content-Type': 'application/json',
@@ -189,7 +196,8 @@ export function createApiClient(serverUrl) {
189
196
  const params = new URLSearchParams({ limit: String(limit) });
190
197
  if (cursor)
191
198
  params.set('cursor', cursor);
192
- const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
199
+ const res = await relayFetch({
200
+ url: `${baseUrl}/v1/export?${params.toString()}`,
193
201
  method: 'GET',
194
202
  headers: buildRelayHeaders({
195
203
  Authorization: `Bearer ${authKeyHex}`,
@@ -215,7 +223,7 @@ export function createApiClient(serverUrl) {
215
223
  */
216
224
  async health() {
217
225
  try {
218
- const res = await fetch(`${baseUrl}/health`, { method: 'GET' });
226
+ const res = await relayFetch({ url: `${baseUrl}/health`, method: 'GET' });
219
227
  return res.status === 200;
220
228
  }
221
229
  catch {