@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
@@ -0,0 +1,40 @@
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
+ import { isGnosisBatchEnabledAtBoot } from './entry.js';
27
+ const GNOSIS_CHAIN_ID = 100;
28
+ export function shouldBatchOnChain(chainId) {
29
+ if (!isGnosisBatchEnabledAtBoot())
30
+ return false;
31
+ return chainId === GNOSIS_CHAIN_ID;
32
+ }
33
+ export const __testing = {
34
+ readGateForTests(env, chainId) {
35
+ const enabled = (env.TOTALRECLAW_GNOSIS_BATCH_ENABLED ?? '').toLowerCase() !== 'false';
36
+ if (!enabled)
37
+ return false;
38
+ return chainId === GNOSIS_CHAIN_ID;
39
+ },
40
+ };
@@ -11,8 +11,9 @@
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`)
16
17
  * - does NOT import anything that performs outbound I/O
17
18
  *
18
19
  * Do NOT add any outbound-request call to this file — a single match for
@@ -33,25 +34,21 @@ export const BILLING_CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours
33
34
  // Chain-id sync
34
35
  // ---------------------------------------------------------------------------
35
36
  /**
36
- * Apply the billing tier to the runtime chain override.
37
+ * Apply the relay's authoritative `chain_id` to the runtime chain override.
37
38
  *
38
- * Pro tier chain 100 (Gnosis mainnet). Free tier (or unknown) stays on
39
- * 84532 (Base Sepolia). The relay routes Pro UserOps to Gnosis, so the
40
- * client MUST sign them against chain 100 otherwise the bundler returns
41
- * AA23 (invalid signature). See MCP's equivalent path in mcp/src/index.ts.
39
+ * After ops-1 (2026-06-05) both tiers run on Gnosis (chain 100) and the relay
40
+ * returns an authoritative `chain_id` in `/v1/billing/status`. The client MUST
41
+ * consume that verbatim the old tier→chain derivation (Free 84532 Base
42
+ * Sepolia) was retired two-tier logic that mis-signed FREE-tier UserOps
43
+ * against the wrong chain and queried a Base-Sepolia RPC for a Gnosis-deployed
44
+ * sender, producing deterministic AA10 (#402).
42
45
  *
43
- * Called from `readBillingCache` and `writeBillingCache` so that every cache
44
- * read or write keeps the chain override in sync with the cached tier.
45
- * Idempotent calling with the same tier is a no-op.
46
+ * A missing / non-finite `chain_id` (older relay, partial payload) defaults to
47
+ * 100 never 84532. Called from `readBillingCache` and `writeBillingCache` so
48
+ * every cache read or write keeps the override in sync. Idempotent.
46
49
  */
47
- export function syncChainIdFromTier(tier) {
48
- if (tier === 'pro') {
49
- setChainIdOverride(100);
50
- }
51
- else {
52
- // Free or unknown → reset to the default free-tier chain.
53
- setChainIdOverride(84532);
54
- }
50
+ export function syncChainIdFromBilling(chainId) {
51
+ setChainIdOverride(typeof chainId === 'number' && Number.isFinite(chainId) ? chainId : 100);
55
52
  }
56
53
  // ---------------------------------------------------------------------------
57
54
  // Read / write
@@ -71,8 +68,9 @@ export function readBillingCache() {
71
68
  const raw = JSON.parse(fs.readFileSync(BILLING_CACHE_PATH, 'utf-8'));
72
69
  if (!raw.checked_at || Date.now() - raw.checked_at > BILLING_CACHE_TTL)
73
70
  return null;
74
- // Keep chain override in sync with persisted tier across process restarts.
75
- syncChainIdFromTier(raw.tier);
71
+ // Keep chain override in sync with the persisted authoritative chain_id
72
+ // across process restarts.
73
+ syncChainIdFromBilling(raw.chain_id);
76
74
  return raw;
77
75
  }
78
76
  catch {
@@ -96,5 +94,5 @@ export function writeBillingCache(cache) {
96
94
  }
97
95
  // Sync chain override AFTER the write so in-process UserOp signing picks
98
96
  // up the correct chain immediately, even if the disk write failed.
99
- syncChainIdFromTier(cache.tier);
97
+ syncChainIdFromBilling(cache.chain_id);
100
98
  }
@@ -11,6 +11,7 @@
11
11
  import crypto from 'node:crypto';
12
12
  import { createRequire } from 'node:module';
13
13
  import { isValidMemoryType, isValidMemoryTypeV1, V0_TO_V1_TYPE, VALID_MEMORY_SCOPES, VALID_MEMORY_SOURCES, VALID_MEMORY_VOLATILITIES, VALID_MEMORY_TYPES_V1, } from './extractor.js';
14
+ import { envStringLower } from './entry.js';
14
15
  // Lazy-load WASM. We use createRequire so this module loads cleanly under
15
16
  // both the OpenClaw runtime (CJS-ish tsx) and bare Node ESM (used by tests).
16
17
  const requireWasm = createRequire(import.meta.url);
@@ -181,6 +182,11 @@ export function buildCanonicalClaimV1(input) {
181
182
  if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
182
183
  canonical.embedding_model_id = input.embeddingModelId;
183
184
  }
185
+ // am-1: Crystal-shaped debrief structured metadata. Re-attached after
186
+ // validation using the same plugin-extra pattern as volatility.
187
+ if (fact.crystalMetadata) {
188
+ canonical.metadata = fact.crystalMetadata;
189
+ }
184
190
  return JSON.stringify(canonical);
185
191
  }
186
192
  /**
@@ -406,7 +412,7 @@ export function resolveDigestMode() {
406
412
  * @internal Not public config — emergency kill-switch only.
407
413
  */
408
414
  export function resolveAutoResolveMode() {
409
- const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
415
+ const raw = envStringLower('TOTALRECLAW_AUTO_RESOLVE_MODE');
410
416
  if (raw === 'off')
411
417
  return 'off';
412
418
  if (raw === 'shadow')
package/dist/config.js CHANGED
@@ -90,10 +90,10 @@ export function getSessionId() {
90
90
  }
91
91
  /**
92
92
  * Runtime override for chain ID, set after the relay billing response is
93
- * read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
94
- * (Gnosis mainnet). The relay routes Pro writes to Gnosis, so Pro-tier
95
- * UserOps MUST be signed against chain 100 otherwise the bundler rejects
96
- * the signature with AA23.
93
+ * read. After the ops-1 single-chain migration (2026-05), ALL tiers (free
94
+ * + Pro) are on Gnosis mainnet (chain 100). The default below is 100.
95
+ * The relay routes all writes to Gnosis, so UserOps MUST be signed against
96
+ * chain 100 — otherwise the bundler rejects the signature with AA24.
97
97
  *
98
98
  * See index.ts: after the billing lookup completes, call
99
99
  * `setChainIdOverride(100)` for Pro users. Free users can leave the
@@ -133,6 +133,42 @@ export const CONFIG = {
133
133
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
134
134
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
135
135
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
136
+ // cred-3 stage 1 — credential-provider abstraction.
137
+ //
138
+ // `file` (default) preserves the legacy behavior: read/write
139
+ // `credentialsPath` directly via fs-helpers.
140
+ //
141
+ // `external` instructs the plugin to load credentials from an
142
+ // out-of-process source at boot. Two transports are supported (mutually
143
+ // exclusive — JSON wins if both set):
144
+ // 1. `externalCredentialsJson` — raw JSON string of CredentialsFile,
145
+ // injected at process start by the secret manager (Railway secrets,
146
+ // Docker `--env-file`, K8s envFrom). Most ergonomic for cloud
147
+ // deployments where the secret manager already exposes secrets as
148
+ // env vars.
149
+ // 2. `externalCredentialsPath` — filesystem path to a JSON file the
150
+ // secret manager mounts (Docker Compose `secrets:` block,
151
+ // K8s secret volumeMount, tmpfs populated by an ops wrapper that
152
+ // fetches from AWS Secrets Manager / Hetzner Vault before boot).
153
+ //
154
+ // `external` mode is read-only: write/clear are no-ops with a warning
155
+ // (the secret manager owns the source of truth — the plugin must not
156
+ // write back through this path).
157
+ //
158
+ // See `docs/guides/production-deployment.md` for the LUKS / dm-crypt
159
+ // pattern and managed-secret-store wiring examples.
160
+ credentialsProvider: (() => {
161
+ const v = (process.env.TOTALRECLAW_CREDENTIALS_PROVIDER ?? '').trim().toLowerCase();
162
+ return v === 'external' ? 'external' : 'file';
163
+ })(),
164
+ externalCredentialsJson: (() => {
165
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_JSON ?? '').trim();
166
+ return v.length > 0 ? v : null;
167
+ })(),
168
+ externalCredentialsPath: (() => {
169
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_PATH ?? '').trim();
170
+ return v.length > 0 ? v : null;
171
+ })(),
136
172
  // 3.2.0 onboarding state file — separate from credentials.json so it
137
173
  // never contains secrets. Loaded on every plugin init + on every
138
174
  // before_tool_call gate check.
@@ -166,19 +202,19 @@ export const CONFIG = {
166
202
  || (process.env.TOTALRECLAW_SERVER_URL
167
203
  ? process.env.TOTALRECLAW_SERVER_URL.replace(/^https?:\/\//, 'wss://').replace(/^http:/, 'ws:')
168
204
  : 'wss://api.totalreclaw.xyz')).replace(/\/+$/, ''),
169
- // Chain — chainId is no longer user-configurable. It is auto-detected from
170
- // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
171
- // 100). The default here is used only before the first billing lookup
205
+ // Chain — chainId is no longer user-configurable. After the ops-1 single-
206
+ // chain migration, ALL tiers are on Gnosis (100). The default here is 100.
172
207
  // completes. Self-hosted users can still point at a custom DataEdge via
173
208
  // TOTALRECLAW_DATA_EDGE_ADDRESS / TOTALRECLAW_ENTRYPOINT_ADDRESS /
174
209
  // TOTALRECLAW_RPC_URL (undocumented; internal knobs).
175
210
  //
176
- // Reads the runtime override set by the billing auto-detect in index.ts.
177
- // Falls back to 84532 (free tier / pre-billing-lookup). Must be a getter,
178
- // not a literal a literal would freeze all Pro-tier UserOps to the
179
- // wrong chainId and AA23 at the bundler.
211
+ // Reads the runtime override set from the relay's authoritative chain_id
212
+ // (syncChainIdFromBilling). Falls back to 100 (Gnosis) pre-billing-lookup
213
+ // after ops-1 both tiers are on Gnosis, so 84532 is never the default (#402).
214
+ // Must be a getter, not a literal — a literal would freeze UserOps to the
215
+ // wrong chainId and fail signature validation at the bundler.
180
216
  get chainId() {
181
- return _chainIdOverride ?? 84532;
217
+ return _chainIdOverride ?? 100;
182
218
  },
183
219
  dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '',
184
220
  entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || '',
@@ -202,6 +238,12 @@ export const CONFIG = {
202
238
  // See: docs/specs/totalreclaw/client-consistency.md
203
239
  cosineThreshold: parseFloat(process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15'),
204
240
  extractInterval: parseInt(process.env.TOTALRECLAW_EXTRACT_INTERVAL ?? process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '3', 10),
241
+ // Self-hosted fallback for max-facts-per-extraction. `undefined` when the
242
+ // env var is unset so getMaxFactsPerExtraction() can fall through to the
243
+ // billing cache then the built-in MAX_FACTS_PER_EXTRACTION constant.
244
+ maxFactsPerExtraction: process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION
245
+ ? parseInt(process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION, 10)
246
+ : undefined,
205
247
  relevanceThreshold: parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3'),
206
248
  semanticSkipThreshold: parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85'),
207
249
  cacheTtlMs: parseInt(process.env.TOTALRECLAW_CACHE_TTL_MS ?? String(5 * 60 * 1000), 10),
@@ -18,10 +18,13 @@
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
  import { createRequire } from 'node:module';
24
26
  import { cosineSimilarity } from './reranker.js';
27
+ import { envNumber } from './entry.js';
25
28
  // ---------------------------------------------------------------------------
26
29
  // Lazy-load WASM core (mirrors claims-helper.ts / contradiction-sync.ts
27
30
  // pattern — plays nicely under both the OpenClaw runtime (CJS-ish tsx) and
@@ -44,13 +47,7 @@ function getWasm() {
44
47
  * Must be a number in [0, 1]. Falls back to 0.85 if invalid or unset.
45
48
  */
46
49
  export function getStoreDedupThreshold() {
47
- const envVal = process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
48
- if (envVal !== undefined) {
49
- const parsed = parseFloat(envVal);
50
- if (!isNaN(parsed) && parsed >= 0 && parsed <= 1)
51
- return parsed;
52
- }
53
- return 0.85;
50
+ return envNumber('TOTALRECLAW_STORE_DEDUP_THRESHOLD', 0.85, { min: 0, max: 1 });
54
51
  }
55
52
  /**
56
53
  * Get the cosine similarity threshold for bulk consolidation clustering.
@@ -59,13 +56,7 @@ export function getStoreDedupThreshold() {
59
56
  * Must be a number in [0, 1]. Falls back to 0.88 if invalid or unset.
60
57
  */
61
58
  export function getConsolidationThreshold() {
62
- const envVal = process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
63
- if (envVal !== undefined) {
64
- const parsed = parseFloat(envVal);
65
- if (!isNaN(parsed) && parsed >= 0 && parsed <= 1)
66
- return parsed;
67
- }
68
- return 0.88;
59
+ return envNumber('TOTALRECLAW_CONSOLIDATION_THRESHOLD', 0.88, { min: 0, max: 1 });
69
60
  }
70
61
  /** Maximum candidates to compare against during store-time dedup. */
71
62
  export const STORE_DEDUP_MAX_CANDIDATES = 200;
@@ -16,8 +16,8 @@
16
16
  */
17
17
  import { createRequire } from 'node:module';
18
18
  import fs from 'node:fs';
19
- import os from 'node:os';
20
19
  import path from 'node:path';
20
+ import { envHomedir, envNumber, envString, envStringLower, } from './entry.js';
21
21
  import { computeEntityTrapdoor, isDigestBlob, } from './claims-helper.js';
22
22
  const requireWasm = createRequire(import.meta.url);
23
23
  let _wasm = null;
@@ -31,10 +31,10 @@ function getWasm() {
31
31
  // ---------------------------------------------------------------------------
32
32
  /** Where feedback, decisions, and weights live. `~/.totalreclaw/` by default. */
33
33
  function resolveStateDir() {
34
- const override = process.env.TOTALRECLAW_STATE_DIR;
35
- if (override && override.length > 0)
34
+ const override = envString('TOTALRECLAW_STATE_DIR');
35
+ if (override.length > 0)
36
36
  return override;
37
- return path.join(os.homedir(), '.totalreclaw');
37
+ return path.join(envHomedir(), '.totalreclaw');
38
38
  }
39
39
  function ensureStateDir() {
40
40
  const dir = resolveStateDir();
@@ -292,7 +292,11 @@ function _resolveWithCandidatesCore(core, input, items, byId) {
292
292
  const { newClaim, newClaimId, newEmbedding, weightsJson, thresholdLower, thresholdUpper, nowUnixSeconds, logger } = input;
293
293
  let actionsJson;
294
294
  try {
295
- actionsJson = core.resolveWithCandidates(JSON.stringify(newClaim), newClaimId, JSON.stringify(newEmbedding), JSON.stringify(items), weightsJson, thresholdLower, thresholdUpper, Math.floor(nowUnixSeconds), TIE_ZONE_SCORE_TOLERANCE);
295
+ actionsJson = core.resolveWithCandidates(JSON.stringify(newClaim), newClaimId, JSON.stringify(newEmbedding), JSON.stringify(items), weightsJson, thresholdLower, thresholdUpper,
296
+ // wasm-bindgen declares `now_unix` as i64 → JS BigInt. Node ≥26 rejects
297
+ // the implicit Number→BigInt coercion that older runtimes tolerated, so
298
+ // wrap explicitly (same as the legacy path below + loadWeightsFile).
299
+ BigInt(Math.floor(nowUnixSeconds)), TIE_ZONE_SCORE_TOLERANCE);
296
300
  }
297
301
  catch (err) {
298
302
  const msg = err instanceof Error ? err.message : String(err);
@@ -453,7 +457,7 @@ function _resolveWithLegacyPipeline(core, input, items, byId) {
453
457
  export async function detectAndResolveContradictions(input) {
454
458
  const { newClaim, newClaimId, newEmbedding, subgraphOwner, authKeyHex, encryptionKey, deps, logger, } = input;
455
459
  // Read env per-call so tests can toggle without module reload.
456
- const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
460
+ const raw = envStringLower('TOTALRECLAW_AUTO_RESOLVE_MODE');
457
461
  const mode = raw === 'off' ? 'off' : raw === 'shadow' ? 'shadow' : 'active';
458
462
  if (mode === 'off')
459
463
  return [];
@@ -687,15 +691,11 @@ export const TUNING_LOOP_MIN_INTERVAL_SECONDS = 3600;
687
691
  * empty, non-numeric, or negative.
688
692
  */
689
693
  export function getTuningLoopMinIntervalSeconds() {
690
- const raw = process.env.TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS;
691
- if (raw === undefined || raw === null || raw === '') {
692
- return TUNING_LOOP_MIN_INTERVAL_SECONDS;
693
- }
694
- const parsed = Number(raw);
695
- if (!Number.isFinite(parsed) || parsed < 0) {
696
- return TUNING_LOOP_MIN_INTERVAL_SECONDS;
697
- }
698
- return parsed;
694
+ // envNumber returns the fallback for unset/empty/non-finite/negative —
695
+ // recovering the original 3-guard contract in one call.
696
+ return envNumber('TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS', TUNING_LOOP_MIN_INTERVAL_SECONDS, {
697
+ min: 0,
698
+ });
699
699
  }
700
700
  /**
701
701
  * Walk `decisions.jsonl` in reverse and find the most recent `supersede_existing`
@@ -0,0 +1,145 @@
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
+ import fs from 'node:fs';
37
+ import { CONFIG } from './config.js';
38
+ import { loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, } from './fs-helpers.js';
39
+ /**
40
+ * Default provider — delegates to the existing fs-helpers functions.
41
+ * Behavior is identical to direct `loadCredentialsJson` / `writeCredentialsJson`
42
+ * / `deleteCredentialsFile` calls, so wiring a call site through this
43
+ * provider in `file` mode produces zero behavior delta.
44
+ */
45
+ export class FileCredentialProvider {
46
+ credentialsPath;
47
+ mode = 'file';
48
+ constructor(credentialsPath) {
49
+ this.credentialsPath = credentialsPath;
50
+ }
51
+ load() {
52
+ return loadCredentialsJson(this.credentialsPath);
53
+ }
54
+ save(creds) {
55
+ return writeCredentialsJson(this.credentialsPath, creds);
56
+ }
57
+ clear() {
58
+ return deleteCredentialsFile(this.credentialsPath);
59
+ }
60
+ }
61
+ /**
62
+ * External provider — reads credentials from a secret manager via one of
63
+ * two transports (env-injected JSON wins if both are set).
64
+ *
65
+ * Inline JSON (`TOTALRECLAW_EXTERNAL_CREDENTIALS_JSON`)
66
+ * The raw `CredentialsFile` JSON, injected as an env var at process
67
+ * start. Most ergonomic for managed platforms that expose secrets as
68
+ * env vars (Railway secrets, Heroku config vars, K8s `envFrom`).
69
+ *
70
+ * File mount (`TOTALRECLAW_EXTERNAL_CREDENTIALS_PATH`)
71
+ * A path to a JSON file the secret manager mounts. Pattern works for
72
+ * Docker Compose `secrets:` blocks, K8s secret `volumeMount`s, and
73
+ * tmpfs paths populated by an ops wrapper script that fetched the
74
+ * payload from a vault (AWS Secrets Manager, Hetzner Vault, etc.)
75
+ * before plugin start.
76
+ *
77
+ * Both flavors are read-only — `save()` and `clear()` return `false` and
78
+ * the caller logs a warn. The secret manager is the canonical source;
79
+ * writing back would create two competing truths.
80
+ */
81
+ export class ExternalCredentialProvider {
82
+ options;
83
+ mode = 'external';
84
+ constructor(options) {
85
+ this.options = options;
86
+ }
87
+ load() {
88
+ const raw = this.readRaw();
89
+ if (raw === null)
90
+ return null;
91
+ try {
92
+ return JSON.parse(raw);
93
+ }
94
+ catch {
95
+ // Parse error — return null so the bootstrap path can decide how to
96
+ // surface (typically: log + fall through to fresh-generate, same as
97
+ // a corrupt credentials.json on disk). We deliberately do not echo
98
+ // the payload here even on parse failure.
99
+ return null;
100
+ }
101
+ }
102
+ save(_creds) {
103
+ return false;
104
+ }
105
+ clear() {
106
+ return false;
107
+ }
108
+ readRaw() {
109
+ if (this.options.inlineJson !== null) {
110
+ return this.options.inlineJson;
111
+ }
112
+ if (this.options.filePath !== null) {
113
+ try {
114
+ if (!fs.existsSync(this.options.filePath))
115
+ return null;
116
+ return fs.readFileSync(this.options.filePath, 'utf-8');
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * Factory — pick the configured provider. Reads from `CONFIG` (single
127
+ * source of truth for env vars) so tests can override by mutating the
128
+ * config snapshot or by passing explicit deps to the constructors.
129
+ *
130
+ * In `external` mode with neither transport set, we still return an
131
+ * `ExternalCredentialProvider` so the caller observes the configured
132
+ * mode + sees `null` from `load()`. The caller is responsible for
133
+ * surfacing the misconfiguration; we do not silently fall back to
134
+ * `file` mode (that would mask a deploy-time mistake by reading a
135
+ * stale `credentials.json` left on disk).
136
+ */
137
+ export function getCredentialProvider(config = CONFIG) {
138
+ if (config.credentialsProvider === 'external') {
139
+ return new ExternalCredentialProvider({
140
+ inlineJson: config.externalCredentialsJson,
141
+ filePath: config.externalCredentialsPath,
142
+ });
143
+ }
144
+ return new FileCredentialProvider(config.credentialsPath);
145
+ }
package/dist/crypto.js CHANGED
@@ -1,138 +1,18 @@
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
- // Lazy-load WASM. Uses createRequire so this module loads cleanly under bare
18
- // Node ESM the shipped `dist/index.js` declares `"type":"module"`, where
19
- // the CJS `require` global is undefined at runtime. Prior to the rc.21 fix
20
- // this file called bare `require('@totalreclaw/core')` and every consumer
21
- // died with `require is not defined`. Matches the pattern already used by
22
- // claims-helper / consolidation / contradiction-sync / digest-sync / pin /
23
- // retype-setscope. See issue #124.
24
- import { createRequire } from 'node:module';
25
- const requireWasm = createRequire(import.meta.url);
26
- let _wasm = null;
27
- function getWasm() {
28
- if (!_wasm)
29
- _wasm = requireWasm('@totalreclaw/core');
30
- return _wasm;
31
- }
32
- // ---------------------------------------------------------------------------
33
- // BIP-39 Validation
34
- // ---------------------------------------------------------------------------
35
- /**
36
- * Check if the input looks like a BIP-39 mnemonic (12 or 24 words).
37
- *
38
- * Lenient: accepts phrases where all words look like valid BIP-39 words
39
- * (allows invalid checksums, which LLMs sometimes generate).
40
- */
41
- export function isBip39Mnemonic(input) {
42
- const words = input.trim().split(/\s+/);
43
- return words.length === 12 || words.length === 24;
44
- }
45
- // Re-export for backward compatibility
46
- export const validateMnemonic = isBip39Mnemonic;
47
- // ---------------------------------------------------------------------------
48
- // Key Derivation
49
- // ---------------------------------------------------------------------------
50
- /**
51
- * Derive auth, encryption, and dedup keys from a recovery phrase.
52
- *
53
- * Delegates to the WASM module for BIP-39 seed derivation and HKDF
54
- * key separation. Uses the lenient variant for phrases where all words
55
- * are valid but the checksum fails.
56
- *
57
- * @param password - BIP-39 12/24-word mnemonic
58
- * @param existingSalt - Ignored for BIP-39 path (salt is deterministic)
59
- */
60
- export function deriveKeys(password, existingSalt) {
61
- const trimmed = password.trim();
62
- // Try strict validation first, fall back to lenient
63
- let result;
64
- try {
65
- result = getWasm().deriveKeysFromMnemonic(trimmed);
66
- }
67
- catch {
68
- result = getWasm().deriveKeysFromMnemonicLenient(trimmed);
69
- }
70
- return {
71
- authKey: Buffer.from(result.auth_key, 'hex'),
72
- encryptionKey: Buffer.from(result.encryption_key, 'hex'),
73
- dedupKey: Buffer.from(result.dedup_key, 'hex'),
74
- salt: Buffer.from(result.salt, 'hex'),
75
- };
76
- }
77
- // ---------------------------------------------------------------------------
78
- // LSH Seed Derivation
79
- // ---------------------------------------------------------------------------
80
- /**
81
- * Derive a 32-byte seed for the LSH hasher.
82
- *
83
- * Delegates to the WASM module.
84
- */
85
- export function deriveLshSeed(password, salt) {
86
- const seedHex = getWasm().deriveLshSeed(password.trim(), salt.toString('hex'));
87
- return new Uint8Array(Buffer.from(seedHex, 'hex'));
88
- }
89
- // ---------------------------------------------------------------------------
90
- // Auth Key Hash
91
- // ---------------------------------------------------------------------------
92
- /**
93
- * Compute the SHA-256 hash of the auth key.
94
- */
95
- export function computeAuthKeyHash(authKey) {
96
- return getWasm().computeAuthKeyHash(authKey.toString('hex'));
97
- }
98
- // ---------------------------------------------------------------------------
99
- // XChaCha20-Poly1305 Encrypt / Decrypt
100
- // ---------------------------------------------------------------------------
101
- /**
102
- * Encrypt a UTF-8 plaintext string with XChaCha20-Poly1305.
103
- *
104
- * Wire format (base64-encoded):
105
- * [nonce: 24 bytes][tag: 16 bytes][ciphertext: variable]
106
- */
107
- export function encrypt(plaintext, encryptionKey) {
108
- return getWasm().encrypt(plaintext, encryptionKey.toString('hex'));
109
- }
110
- /**
111
- * Decrypt a base64-encoded XChaCha20-Poly1305 blob back to a UTF-8 string.
112
- */
113
- export function decrypt(encryptedBase64, encryptionKey) {
114
- return getWasm().decrypt(encryptedBase64, encryptionKey.toString('hex'));
115
- }
116
- // ---------------------------------------------------------------------------
117
- // Blind Indices
118
- // ---------------------------------------------------------------------------
119
- /**
120
- * Generate blind indices (SHA-256 hashes of tokens) for a text string.
121
- *
122
- * Delegates to the WASM module which performs tokenization, stemming,
123
- * and SHA-256 hashing.
124
- */
125
- export function generateBlindIndices(text) {
126
- return getWasm().generateBlindIndices(text);
127
- }
128
- // ---------------------------------------------------------------------------
129
- // Content Fingerprint (Dedup)
130
- // ---------------------------------------------------------------------------
131
- /**
132
- * Compute an HMAC-SHA256 content fingerprint for exact-duplicate detection.
133
- *
134
- * @returns 64-character hex string.
135
- */
136
- export function generateContentFingerprint(plaintext, dedupKey) {
137
- return getWasm().generateContentFingerprint(plaintext, dedupKey.toString('hex'));
138
- }
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 { isBip39Mnemonic, validateMnemonic, deriveKeys, deriveLshSeed, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './vault-crypto.js';