@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
@@ -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
@@ -22,7 +26,9 @@
22
26
  */
23
27
  import fs from 'node:fs';
24
28
  import path from 'node:path';
25
- import { CONFIG, setChainIdOverride } from './config.js';
29
+ import { CONFIG, setChainIdOverride, setDataEdgeAddressOverride } from './config.js';
30
+ /** A plausible EVM address — anything else from billing is ignored (#460). */
31
+ const DATA_EDGE_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
26
32
  // ---------------------------------------------------------------------------
27
33
  // Constants
28
34
  // ---------------------------------------------------------------------------
@@ -33,25 +39,45 @@ export const BILLING_CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours
33
39
  // Chain-id sync
34
40
  // ---------------------------------------------------------------------------
35
41
  /**
36
- * Apply the billing tier to the runtime chain override.
42
+ * Apply the relay's authoritative `chain_id` to the runtime chain override.
37
43
  *
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.
44
+ * After ops-1 (2026-06-05) both tiers run on Gnosis (chain 100) and the relay
45
+ * returns an authoritative `chain_id` in `/v1/billing/status`. The client MUST
46
+ * consume that verbatim the old tier→chain derivation (Free 84532 Base
47
+ * Sepolia) was retired two-tier logic that mis-signed FREE-tier UserOps
48
+ * against the wrong chain and queried a Base-Sepolia RPC for a Gnosis-deployed
49
+ * sender, producing deterministic AA10 (#402).
42
50
  *
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.
51
+ * A missing / non-finite `chain_id` (older relay, partial payload) defaults to
52
+ * 100 never 84532. Called from `readBillingCache` and `writeBillingCache` so
53
+ * every cache read or write keeps the override in sync. Idempotent.
46
54
  */
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
- }
55
+ export function syncChainIdFromBilling(chainId) {
56
+ setChainIdOverride(typeof chainId === 'number' && Number.isFinite(chainId) ? chainId : 100);
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // DataEdge-address sync
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Apply the relay's authoritative `data_edge_address` to the runtime DataEdge
63
+ * override. Mirrors `syncChainIdFromBilling`.
64
+ *
65
+ * The relay routes each environment to its own DataEdge (staging is on-chain
66
+ * isolated). If the client ignores this and uses the WASM-baked default (the
67
+ * PROD DataEdge), writes against the staging relay mine on the prod contract
68
+ * while reads come from the staging subgraph → empty recall + phantom
69
+ * "stored=N" success (#460).
70
+ *
71
+ * A missing / malformed `data_edge_address` (older relay, partial payload,
72
+ * junk) clears the override (`null`) so resolution falls through to the WASM
73
+ * default — never a stale value. Only a plausible address
74
+ * (`0x` + 40 hex) is honored. Called from `readBillingCache` and
75
+ * `writeBillingCache` so every cache read or write keeps the override in sync.
76
+ * Idempotent. The explicit env override (`TOTALRECLAW_DATA_EDGE_ADDRESS`)
77
+ * still wins — it is the first term in `getSubgraphConfig`.
78
+ */
79
+ export function syncDataEdgeAddressFromBilling(address) {
80
+ setDataEdgeAddressOverride(typeof address === 'string' && DATA_EDGE_ADDRESS_RE.test(address) ? address : null);
55
81
  }
56
82
  // ---------------------------------------------------------------------------
57
83
  // Read / write
@@ -71,8 +97,10 @@ export function readBillingCache() {
71
97
  const raw = JSON.parse(fs.readFileSync(BILLING_CACHE_PATH, 'utf-8'));
72
98
  if (!raw.checked_at || Date.now() - raw.checked_at > BILLING_CACHE_TTL)
73
99
  return null;
74
- // Keep chain override in sync with persisted tier across process restarts.
75
- syncChainIdFromTier(raw.tier);
100
+ // Keep chain + DataEdge overrides in sync with the persisted authoritative
101
+ // values across process restarts.
102
+ syncChainIdFromBilling(raw.chain_id);
103
+ syncDataEdgeAddressFromBilling(raw.data_edge_address);
76
104
  return raw;
77
105
  }
78
106
  catch {
@@ -94,7 +122,9 @@ export function writeBillingCache(cache) {
94
122
  catch {
95
123
  // Best-effort — don't block on cache write failure.
96
124
  }
97
- // Sync chain override AFTER the write so in-process UserOp signing picks
98
- // up the correct chain immediately, even if the disk write failed.
99
- syncChainIdFromTier(cache.tier);
125
+ // Sync chain + DataEdge overrides AFTER the write so in-process UserOp
126
+ // signing + subgraph reads pick up the correct chain and contract
127
+ // immediately, even if the disk write failed.
128
+ syncChainIdFromBilling(cache.chain_id);
129
+ syncDataEdgeAddressFromBilling(cache.data_edge_address);
100
130
  }
@@ -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);
@@ -411,7 +412,7 @@ export function resolveDigestMode() {
411
412
  * @internal Not public config — emergency kill-switch only.
412
413
  */
413
414
  export function resolveAutoResolveMode() {
414
- const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
415
+ const raw = envStringLower('TOTALRECLAW_AUTO_RESOLVE_MODE');
415
416
  if (raw === 'off')
416
417
  return 'off';
417
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
@@ -107,6 +107,35 @@ export function setChainIdOverride(chainId) {
107
107
  export function __resetChainIdOverrideForTests() {
108
108
  _chainIdOverride = null;
109
109
  }
110
+ /**
111
+ * Runtime override for the DataEdge contract address, set after the relay
112
+ * billing response is read. The relay returns an authoritative
113
+ * `data_edge_address` in `/v1/billing/status` (staging routes to the isolated
114
+ * staging DataEdge, production to the prod DataEdge). Chain-aware clients MUST
115
+ * consume it verbatim — otherwise writes mine on the WASM-baked prod DataEdge
116
+ * while reads hit the relay's subgraph, so recall silently returns empty
117
+ * against staging (#460). Mirrors `_chainIdOverride`.
118
+ *
119
+ * `null` means "no billing override" → fall through to the WASM default. The
120
+ * explicit operator env override (`TOTALRECLAW_DATA_EDGE_ADDRESS`, exposed as
121
+ * `CONFIG.dataEdgeAddress`) always wins over this.
122
+ */
123
+ let _dataEdgeAddressOverride = null;
124
+ export function setDataEdgeAddressOverride(address) {
125
+ _dataEdgeAddressOverride = address;
126
+ }
127
+ /**
128
+ * Read the billing-sourced DataEdge override (or `''` when unset). Used by
129
+ * `getSubgraphConfig` as the middle term of the env → billing → WASM-default
130
+ * resolution order (#460).
131
+ */
132
+ export function getDataEdgeAddressOverride() {
133
+ return _dataEdgeAddressOverride ?? '';
134
+ }
135
+ /** Reset the DataEdge override — used by tests. */
136
+ export function __resetDataEdgeAddressOverrideForTests() {
137
+ _dataEdgeAddressOverride = null;
138
+ }
110
139
  export const CONFIG = {
111
140
  // Core — recoveryPhrase reads from override first, then env var.
112
141
  // Use getRecoveryPhrase() for dynamic access; this property is for
@@ -133,6 +162,42 @@ export const CONFIG = {
133
162
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
134
163
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
135
164
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
165
+ // cred-3 stage 1 — credential-provider abstraction.
166
+ //
167
+ // `file` (default) preserves the legacy behavior: read/write
168
+ // `credentialsPath` directly via fs-helpers.
169
+ //
170
+ // `external` instructs the plugin to load credentials from an
171
+ // out-of-process source at boot. Two transports are supported (mutually
172
+ // exclusive — JSON wins if both set):
173
+ // 1. `externalCredentialsJson` — raw JSON string of CredentialsFile,
174
+ // injected at process start by the secret manager (Railway secrets,
175
+ // Docker `--env-file`, K8s envFrom). Most ergonomic for cloud
176
+ // deployments where the secret manager already exposes secrets as
177
+ // env vars.
178
+ // 2. `externalCredentialsPath` — filesystem path to a JSON file the
179
+ // secret manager mounts (Docker Compose `secrets:` block,
180
+ // K8s secret volumeMount, tmpfs populated by an ops wrapper that
181
+ // fetches from AWS Secrets Manager / Hetzner Vault before boot).
182
+ //
183
+ // `external` mode is read-only: write/clear are no-ops with a warning
184
+ // (the secret manager owns the source of truth — the plugin must not
185
+ // write back through this path).
186
+ //
187
+ // See `docs/guides/production-deployment.md` for the LUKS / dm-crypt
188
+ // pattern and managed-secret-store wiring examples.
189
+ credentialsProvider: (() => {
190
+ const v = (process.env.TOTALRECLAW_CREDENTIALS_PROVIDER ?? '').trim().toLowerCase();
191
+ return v === 'external' ? 'external' : 'file';
192
+ })(),
193
+ externalCredentialsJson: (() => {
194
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_JSON ?? '').trim();
195
+ return v.length > 0 ? v : null;
196
+ })(),
197
+ externalCredentialsPath: (() => {
198
+ const v = (process.env.TOTALRECLAW_EXTERNAL_CREDENTIALS_PATH ?? '').trim();
199
+ return v.length > 0 ? v : null;
200
+ })(),
136
201
  // 3.2.0 onboarding state file — separate from credentials.json so it
137
202
  // never contains secrets. Loaded on every plugin init + on every
138
203
  // before_tool_call gate check.
@@ -166,21 +231,28 @@ export const CONFIG = {
166
231
  || (process.env.TOTALRECLAW_SERVER_URL
167
232
  ? process.env.TOTALRECLAW_SERVER_URL.replace(/^https?:\/\//, 'wss://').replace(/^http:/, 'ws:')
168
233
  : '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
234
+ // Chain — chainId is no longer user-configurable. After the ops-1 single-
235
+ // chain migration, ALL tiers are on Gnosis (100). The default here is 100.
172
236
  // completes. Self-hosted users can still point at a custom DataEdge via
173
237
  // TOTALRECLAW_DATA_EDGE_ADDRESS / TOTALRECLAW_ENTRYPOINT_ADDRESS /
174
238
  // TOTALRECLAW_RPC_URL (undocumented; internal knobs).
175
239
  //
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.
240
+ // Reads the runtime override set from the relay's authoritative chain_id
241
+ // (syncChainIdFromBilling). Falls back to 100 (Gnosis) pre-billing-lookup
242
+ // after ops-1 both tiers are on Gnosis, so 84532 is never the default (#402).
243
+ // Must be a getter, not a literal — a literal would freeze UserOps to the
244
+ // wrong chainId and fail signature validation at the bundler.
180
245
  get chainId() {
181
- return _chainIdOverride ?? 84532;
246
+ return _chainIdOverride ?? 100;
247
+ },
248
+ // Explicit operator env override for the DataEdge contract. Stays env-only:
249
+ // it is the FIRST term of `getSubgraphConfig`'s env → billing → WASM-default
250
+ // resolution order, so it wins over the relay's authoritative
251
+ // `data_edge_address` (see `getDataEdgeAddressOverride` / #460). Getter, not
252
+ // a literal, so it reflects the live env rather than freezing at module load.
253
+ get dataEdgeAddress() {
254
+ return process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '';
182
255
  },
183
- dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '',
184
256
  entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || '',
185
257
  rpcUrl: process.env.TOTALRECLAW_RPC_URL || '',
186
258
  // 3.3.3-rc.1 (issue #187 — ONNX decouple): kill switch for the
@@ -202,6 +274,12 @@ export const CONFIG = {
202
274
  // See: docs/specs/totalreclaw/client-consistency.md
203
275
  cosineThreshold: parseFloat(process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15'),
204
276
  extractInterval: parseInt(process.env.TOTALRECLAW_EXTRACT_INTERVAL ?? process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '3', 10),
277
+ // Self-hosted fallback for max-facts-per-extraction. `undefined` when the
278
+ // env var is unset so getMaxFactsPerExtraction() can fall through to the
279
+ // billing cache then the built-in MAX_FACTS_PER_EXTRACTION constant.
280
+ maxFactsPerExtraction: process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION
281
+ ? parseInt(process.env.TOTALRECLAW_MAX_FACTS_PER_EXTRACTION, 10)
282
+ : undefined,
205
283
  relevanceThreshold: parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3'),
206
284
  semanticSkipThreshold: parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85'),
207
285
  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
+ }