@exagent/agent 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -26,6 +26,7 @@ import { getLogger, configureLogger } from './logger.js';
26
26
  import type { LogLevel } from './logger.js';
27
27
  import { DiagnosticsCollector } from './diagnostics.js';
28
28
  import type { CycleTimings } from './diagnostics.js';
29
+ import { scrubSecrets } from './scrub-secrets.js';
29
30
 
30
31
  // Hyperliquid
31
32
  import { HyperliquidClient } from './perp/client.js';
@@ -49,7 +50,10 @@ import type { SpotConfig } from './spot/types.js';
49
50
  import { BridgeManager } from './bridge/bridge-manager.js';
50
51
  import type { BridgeConfig } from './bridge/types.js';
51
52
 
52
- const SDK_VERSION = '0.1.0';
53
+ import { createRequire } from 'module';
54
+ const _require = createRequire(import.meta.url);
55
+ let SDK_VERSION = '0.3.0';
56
+ try { SDK_VERSION = _require('../package.json').version; } catch {}
53
57
 
54
58
  /** Number of consecutive cycle failures before switching to idle */
55
59
  const MAX_CONSECUTIVE_FAILURES = 3;
@@ -121,18 +125,7 @@ export class AgentRuntime {
121
125
 
122
126
  this.diagnostics = new DiagnosticsCollector();
123
127
  this.llm = createLLMAdapter(config.llm);
124
-
125
- // Apply LLM daily token budget if configured
126
- if (config.llmBudget?.maxDailyTokens && this.llm instanceof BaseLLMAdapter) {
127
- this.llm.setMaxDailyTokens(config.llmBudget.maxDailyTokens);
128
- }
129
-
130
- // Wire LLM call recording into diagnostics
131
- if (this.llm instanceof BaseLLMAdapter) {
132
- this.llm.setCallRecordCallback((record) => {
133
- this.diagnostics.recordLLMCall(record);
134
- });
135
- }
128
+ this.configureLLMAdapter(this.llm);
136
129
 
137
130
  this.relay = new RelayClient({
138
131
  url: config.relay.url,
@@ -236,6 +229,258 @@ export class AgentRuntime {
236
229
  process.exit(0);
237
230
  }
238
231
 
232
+ private configureLLMAdapter(adapter: LLMAdapter): void {
233
+ if (this.config.llmBudget?.maxDailyTokens && adapter instanceof BaseLLMAdapter) {
234
+ adapter.setMaxDailyTokens(this.config.llmBudget.maxDailyTokens);
235
+ }
236
+
237
+ if (adapter instanceof BaseLLMAdapter) {
238
+ adapter.setCallRecordCallback((record) => {
239
+ this.diagnostics.recordLLMCall(record);
240
+ });
241
+ }
242
+ }
243
+
244
+ private teardownVenues(): void {
245
+ if (this.hlWs) {
246
+ this.hlWs.disconnect();
247
+ }
248
+
249
+ this.hlWs = null;
250
+ this.hlClient = null;
251
+ this.hlSigner = null;
252
+ this.hlOrders = null;
253
+ this.hlPositions = null;
254
+ this.pmClient = null;
255
+ this.pmOrders = null;
256
+ this.spotManager = null;
257
+ this.bridgeManager = null;
258
+ this.walletAddress = null;
259
+ }
260
+
261
+ private async waitForCycleCompletion(timeoutMs: number = 30000): Promise<void> {
262
+ let waitedMs = 0;
263
+ while (this.cycleInProgress && waitedMs < timeoutMs) {
264
+ await new Promise((resolve) => setTimeout(resolve, 100));
265
+ waitedMs += 100;
266
+ }
267
+ }
268
+
269
+ private getConfiguredSpotVenue(): 'aerodrome' | 'uniswap' {
270
+ return this.config.venues?.spot?.defaultChain === 'base' ? 'aerodrome' : 'uniswap';
271
+ }
272
+
273
+ private normalizeVenueForExecution(venue?: string): string | undefined {
274
+ if (!venue) return undefined;
275
+
276
+ if (venue === 'hyperliquid_spot') {
277
+ return this.getConfiguredSpotVenue();
278
+ }
279
+
280
+ if (venue === 'sushiswap') {
281
+ return 'uniswap';
282
+ }
283
+
284
+ return venue;
285
+ }
286
+
287
+ private isVenueConfigured(venue: string): boolean {
288
+ const normalized = this.normalizeVenueForExecution(venue) ?? venue;
289
+
290
+ switch (normalized) {
291
+ case 'hyperliquid_perp':
292
+ case 'hyperliquid_deposit':
293
+ case 'hyperliquid_withdraw':
294
+ return this.config.venues?.hyperliquid_perp?.enabled === true;
295
+ case 'polymarket':
296
+ return this.config.venues?.polymarket?.enabled === true;
297
+ case 'uniswap':
298
+ case 'aerodrome':
299
+ return this.config.venues?.spot?.enabled === true;
300
+ case 'across':
301
+ return this.config.venues?.bridge?.enabled === true;
302
+ default:
303
+ return false;
304
+ }
305
+ }
306
+
307
+ private canExecuteVenue(venue: string): boolean {
308
+ const normalized = this.normalizeVenueForExecution(venue) ?? venue;
309
+ if (!this.isVenueConfigured(normalized)) {
310
+ return false;
311
+ }
312
+
313
+ if (this.paper) {
314
+ return true;
315
+ }
316
+
317
+ switch (normalized) {
318
+ case 'hyperliquid_perp':
319
+ case 'hyperliquid_deposit':
320
+ case 'hyperliquid_withdraw':
321
+ return !!this.hlOrders || !!this.hlSigner;
322
+ case 'polymarket':
323
+ return !!this.pmOrders;
324
+ case 'uniswap':
325
+ case 'aerodrome':
326
+ return !!this.spotManager;
327
+ case 'across':
328
+ return !!this.bridgeManager;
329
+ default:
330
+ return false;
331
+ }
332
+ }
333
+
334
+ private getPreferredExecutionVenues(): string[] {
335
+ const preferred = [
336
+ ...(this.config.strategy.venues ?? []),
337
+ ...(this.config.strategy.prompt?.venues ?? []),
338
+ ];
339
+
340
+ const normalized = preferred
341
+ .map((venue) => this.normalizeVenueForExecution(venue))
342
+ .filter((venue): venue is string => Boolean(venue));
343
+
344
+ const fallback: string[] = [];
345
+ if (this.config.venues?.hyperliquid_perp?.enabled) fallback.push('hyperliquid_perp');
346
+ if (this.config.venues?.polymarket?.enabled) fallback.push('polymarket');
347
+ if (this.config.venues?.spot?.enabled) fallback.push(this.getConfiguredSpotVenue());
348
+ if (this.config.venues?.bridge?.enabled) fallback.push('across');
349
+
350
+ return [...new Set([...normalized, ...fallback])];
351
+ }
352
+
353
+ private buildRuntimeVenuesFromSelection(selectedVenues: string[]): NonNullable<RuntimeConfig['venues']> {
354
+ const selected = new Set(selectedVenues);
355
+ const current = this.config.venues ?? {};
356
+ const spotEnabled = selectedVenues.some((venue) => ['hyperliquid_spot', 'uniswap', 'aerodrome', 'sushiswap'].includes(venue));
357
+ const multiChainSpot = selected.has('uniswap') || selected.has('sushiswap');
358
+ const defaultSpotChains = multiChainSpot
359
+ ? ['base', 'ethereum', 'arbitrum', 'polygon']
360
+ : ['base'];
361
+
362
+ return {
363
+ hyperliquid_perp: {
364
+ enabled: selected.has('hyperliquid_perp'),
365
+ apiUrl: current.hyperliquid_perp?.apiUrl ?? 'https://api.hyperliquid.xyz',
366
+ wsUrl: current.hyperliquid_perp?.wsUrl ?? 'wss://api.hyperliquid.xyz/ws',
367
+ maxLeverage: current.hyperliquid_perp?.maxLeverage ?? 10,
368
+ maxNotionalUSD: current.hyperliquid_perp?.maxNotionalUSD ?? 50000,
369
+ allowedInstruments: current.hyperliquid_perp?.allowedInstruments,
370
+ },
371
+ polymarket: {
372
+ enabled: selected.has('polymarket'),
373
+ clobApiUrl: current.polymarket?.clobApiUrl ?? 'https://clob.polymarket.com',
374
+ gammaApiUrl: current.polymarket?.gammaApiUrl ?? 'https://gamma-api.polymarket.com',
375
+ maxNotionalUSD: current.polymarket?.maxNotionalUSD ?? 1000,
376
+ maxTotalExposureUSD: current.polymarket?.maxTotalExposureUSD ?? 5000,
377
+ allowedCategories: current.polymarket?.allowedCategories,
378
+ },
379
+ spot: {
380
+ enabled: spotEnabled,
381
+ chains: current.spot?.chains?.length ? Array.from(new Set([...defaultSpotChains, ...current.spot.chains])) : defaultSpotChains,
382
+ defaultChain: current.spot?.defaultChain ?? 'base',
383
+ maxSlippageBps: current.spot?.maxSlippageBps ?? this.config.trading.maxSlippageBps,
384
+ maxSwapValueUSD: current.spot?.maxSwapValueUSD ?? 10000,
385
+ },
386
+ bridge: {
387
+ enabled: selected.has('across'),
388
+ defaultBridge: current.bridge?.defaultBridge ?? 'across',
389
+ maxBridgeValueUSD: current.bridge?.maxBridgeValueUSD ?? 10000,
390
+ fillTimeoutMs: current.bridge?.fillTimeoutMs ?? 300000,
391
+ pollIntervalMs: current.bridge?.pollIntervalMs ?? 2000,
392
+ },
393
+ };
394
+ }
395
+
396
+ private buildStrategyFallbackPrompt(strategy: {
397
+ name?: string;
398
+ description?: string;
399
+ category?: string;
400
+ venues?: string[];
401
+ }): string {
402
+ const name = strategy.name?.trim() || 'Dashboard Strategy';
403
+ const description = strategy.description?.trim();
404
+ const category = strategy.category?.trim();
405
+ const venues = strategy.venues?.length ? strategy.venues.join(', ') : 'any';
406
+
407
+ return [
408
+ `You are the "${name}" trading agent.`,
409
+ category ? `Strategy category: ${category}.` : null,
410
+ description
411
+ ? `Strategy description: ${description}`
412
+ : 'Strategy description: follow the owner-configured strategy exactly and do not improvise outside it.',
413
+ `Allowed venues: ${venues}.`,
414
+ 'Stay inside the owner-configured scope. If the setup is unclear or the trade does not fit, return no trades.',
415
+ 'Return ONLY a JSON array of trade signals.',
416
+ ].filter((line): line is string => Boolean(line)).join('\n');
417
+ }
418
+
419
+ private extractStrategyConfigFromAgentConfig(cfg: Record<string, unknown>): RuntimeConfig['strategy'] {
420
+ const rawStrategy = cfg.strategy as Record<string, unknown> | undefined;
421
+ if (!rawStrategy) {
422
+ return { template: 'hold' };
423
+ }
424
+
425
+ const venues = Array.isArray(rawStrategy.venues)
426
+ ? rawStrategy.venues.filter((venue): venue is string => typeof venue === 'string')
427
+ : undefined;
428
+
429
+ if (typeof rawStrategy.file === 'string' && rawStrategy.file.trim()) {
430
+ return { file: rawStrategy.file, venues };
431
+ }
432
+
433
+ if (typeof rawStrategy.code === 'string' && rawStrategy.code.trim()) {
434
+ return { code: rawStrategy.code, venues };
435
+ }
436
+
437
+ if (typeof rawStrategy.systemPrompt === 'string' && rawStrategy.systemPrompt.trim()) {
438
+ return {
439
+ venues,
440
+ prompt: {
441
+ name: typeof rawStrategy.name === 'string' ? rawStrategy.name : undefined,
442
+ systemPrompt: rawStrategy.systemPrompt,
443
+ venues,
444
+ },
445
+ };
446
+ }
447
+
448
+ if (typeof rawStrategy.template === 'string' && rawStrategy.template.trim()) {
449
+ return {
450
+ template: rawStrategy.template,
451
+ venues,
452
+ };
453
+ }
454
+
455
+ return {
456
+ venues,
457
+ prompt: {
458
+ name: typeof rawStrategy.name === 'string' ? rawStrategy.name : undefined,
459
+ systemPrompt: this.buildStrategyFallbackPrompt({
460
+ name: typeof rawStrategy.name === 'string' ? rawStrategy.name : undefined,
461
+ description: typeof rawStrategy.description === 'string' ? rawStrategy.description : undefined,
462
+ category: typeof rawStrategy.category === 'string' ? rawStrategy.category : undefined,
463
+ venues,
464
+ }),
465
+ venues,
466
+ },
467
+ };
468
+ }
469
+
470
+ private async applyExecutionMode(): Promise<void> {
471
+ if (this.config.trading.mode === 'paper') {
472
+ this.teardownVenues();
473
+ if (!this.paper) {
474
+ this.paper = new PaperExecutor(this.config.trading.initialCapitalUSD ?? 10000);
475
+ }
476
+ return;
477
+ }
478
+
479
+ this.paper = null;
480
+ this.teardownVenues();
481
+ await this.initializeVenues();
482
+ }
483
+
239
484
  // ── VENUE INITIALIZATION ───────────────────────────────────
240
485
 
241
486
  private async initializeVenues(): Promise<void> {
@@ -476,8 +721,9 @@ export class AgentRuntime {
476
721
  store: this.store,
477
722
  config: this.config.trading,
478
723
  log: (msg: string) => {
479
- log.info('strategy', msg);
480
- this.signal.reportInfo('Strategy Log', msg);
724
+ const safe = scrubSecrets(msg);
725
+ log.info('strategy', safe);
726
+ this.signal.reportInfo('Strategy Log', safe);
481
727
  },
482
728
  });
483
729
  timings.strategyMs = Date.now() - strategyStart;
@@ -598,11 +844,20 @@ export class AgentRuntime {
598
844
  // ── SIGNAL EXECUTION ───────────────────────────────────────
599
845
 
600
846
  private async executeSignal(sig: TradeSignal): Promise<void> {
601
- const venue = sig.venue;
847
+ const venue = sig.venue ? this.normalizeVenueForExecution(sig.venue) ?? sig.venue : sig.venue;
848
+ const signal = venue && venue !== sig.venue
849
+ ? { ...sig, venue }
850
+ : sig;
851
+
852
+ if (signal.venue && !this.isVenueConfigured(signal.venue)) {
853
+ getLogger().warn('runtime', `Venue "${signal.venue}" is not enabled in config — signal dropped`, { symbol: signal.symbol });
854
+ this.signal.reportInfo('Signal Dropped', `${signal.symbol}: venue ${signal.venue} is not enabled in this agent config.`);
855
+ return;
856
+ }
602
857
 
603
858
  if (this.paper) {
604
859
  // Paper trading
605
- const trade = this.paper.execute(sig, this.market);
860
+ const trade = this.paper.execute(signal, this.market);
606
861
  if (trade) {
607
862
  // Record realized PnL for daily loss circuit breaker
608
863
  this.risk.recordTrade(trade.pnl ?? 0, trade.fee);
@@ -613,7 +868,7 @@ export class AgentRuntime {
613
868
  }
614
869
 
615
870
  this.signal.reportTrade({
616
- ...sig,
871
+ ...signal,
617
872
  price: trade.entryPrice,
618
873
  fee: trade.fee,
619
874
  venue: trade.venue,
@@ -626,17 +881,17 @@ export class AgentRuntime {
626
881
 
627
882
  // Live trading — route to venue
628
883
  if (venue === 'hyperliquid_perp' && this.hlOrders) {
629
- await this.executeHyperliquidSignal(sig);
884
+ await this.executeHyperliquidSignal(signal);
630
885
  } else if (venue === 'polymarket' && this.pmOrders) {
631
- await this.executePolymarketSignal(sig);
632
- } else if ((venue === 'uniswap' || venue === 'aerodrome' || venue === 'sushiswap') && this.spotManager) {
633
- await this.executeSpotSignal(sig);
886
+ await this.executePolymarketSignal(signal);
887
+ } else if ((venue === 'uniswap' || venue === 'aerodrome' || venue === 'hyperliquid_spot') && this.spotManager) {
888
+ await this.executeSpotSignal(signal);
634
889
  } else if (venue === 'across' && this.bridgeManager) {
635
- await this.executeBridgeSignal(sig);
890
+ await this.executeBridgeSignal(signal);
636
891
  } else if (venue === 'hyperliquid_deposit' && this.hlSigner) {
637
- await this.executeHyperliquidDeposit(sig);
892
+ await this.executeHyperliquidDeposit(signal);
638
893
  } else if (venue === 'hyperliquid_withdraw' && this.hlSigner) {
639
- await this.executeHyperliquidWithdraw(sig);
894
+ await this.executeHyperliquidWithdraw(signal);
640
895
  } else {
641
896
  getLogger().warn('runtime', `No executor for venue "${venue}" — signal dropped`, { symbol: sig.symbol });
642
897
  }
@@ -685,14 +940,15 @@ export class AgentRuntime {
685
940
  if (!result.venueFillId) result.venueFillId = '';
686
941
  if (!result.venueTimestamp) result.venueTimestamp = '';
687
942
 
688
- // Infer venue from config if missing
943
+ if (result.venue) {
944
+ result.venue = this.normalizeVenueForExecution(result.venue) ?? result.venue;
945
+ }
946
+
947
+ // Infer venue from the current strategy + enabled venue config if missing
689
948
  if (!result.venue) {
690
- if (this.hlOrders) {
691
- result.venue = 'hyperliquid_perp';
692
- } else if (this.pmOrders) {
693
- result.venue = 'polymarket';
694
- } else if (this.spotManager) {
695
- result.venue = this.config.venues?.spot?.defaultChain === 'base' ? 'aerodrome' : 'uniswap';
949
+ const candidates = this.getPreferredExecutionVenues().filter((venue) => this.canExecuteVenue(venue));
950
+ if (candidates.length > 0) {
951
+ result.venue = candidates[0];
696
952
  }
697
953
  }
698
954
 
@@ -773,7 +1029,12 @@ export class AgentRuntime {
773
1029
  if (!this.spotManager) return;
774
1030
 
775
1031
  const chain = sig.chain ?? this.config.venues?.spot?.defaultChain ?? 'base';
776
- const dex = sig.venue === 'aerodrome' ? 'aerodrome' : 'uniswap';
1032
+ const routedVenue = this.normalizeVenueForExecution(sig.venue) ?? this.getConfiguredSpotVenue();
1033
+ const dex = routedVenue === 'aerodrome' ? 'aerodrome' : 'uniswap';
1034
+
1035
+ if (sig.venue === 'hyperliquid_spot') {
1036
+ getLogger().info('venue', 'Routing hyperliquid_spot signal through configured spot executor', { routedAs: dex, chain });
1037
+ }
777
1038
 
778
1039
  // For spot, the symbol format is "TOKENIN/TOKENOUT" (e.g., "ETH/USDC")
779
1040
  const parts = sig.symbol.split('/');
@@ -1162,6 +1423,15 @@ export class AgentRuntime {
1162
1423
  const agent = await res.json() as { config?: Record<string, unknown> };
1163
1424
  if (agent.config) {
1164
1425
  const cfg = agent.config as Record<string, unknown>;
1426
+ const previousMode = this.mode;
1427
+
1428
+ if (this.tradingInterval) {
1429
+ clearInterval(this.tradingInterval);
1430
+ this.tradingInterval = null;
1431
+ }
1432
+
1433
+ await this.waitForCycleCompletion();
1434
+
1165
1435
  // Update risk params if present
1166
1436
  const risk = cfg.risk as Record<string, unknown> | undefined;
1167
1437
  if (risk) {
@@ -1179,10 +1449,73 @@ export class AgentRuntime {
1179
1449
  if (typeof risk.tradingIntervalSec === 'number') {
1180
1450
  this.config.trading.tradingIntervalMs = risk.tradingIntervalSec * 1000;
1181
1451
  }
1452
+ if (typeof risk.minTradeValueUSD === 'number') {
1453
+ this.config.trading.minTradeValueUSD = risk.minTradeValueUSD;
1454
+ updates.minTradeValueUSD = risk.minTradeValueUSD;
1455
+ }
1182
1456
  if (Object.keys(updates).length > 0) {
1183
1457
  this.risk.updateParams(updates);
1184
1458
  }
1185
1459
  }
1460
+
1461
+ if (typeof cfg.paperTrading === 'boolean') {
1462
+ this.config.trading.mode = cfg.paperTrading ? 'paper' : 'live';
1463
+ }
1464
+
1465
+ const llm = cfg.llm as Record<string, unknown> | undefined;
1466
+ if (llm) {
1467
+ let llmChanged = false;
1468
+ const nextLlm = { ...this.config.llm };
1469
+
1470
+ if (typeof llm.provider === 'string' && llm.provider !== nextLlm.provider) {
1471
+ nextLlm.provider = llm.provider as RuntimeConfig['llm']['provider'];
1472
+ llmChanged = true;
1473
+ }
1474
+ if (typeof llm.model === 'string' && llm.model !== nextLlm.model) {
1475
+ nextLlm.model = llm.model;
1476
+ llmChanged = true;
1477
+ }
1478
+ if (typeof llm.endpoint === 'string' && llm.endpoint !== nextLlm.endpoint) {
1479
+ nextLlm.endpoint = llm.endpoint;
1480
+ llmChanged = true;
1481
+ }
1482
+ if (typeof llm.temperature === 'number' && llm.temperature !== nextLlm.temperature) {
1483
+ nextLlm.temperature = llm.temperature;
1484
+ llmChanged = true;
1485
+ }
1486
+ if (typeof llm.maxTokens === 'number' && llm.maxTokens !== nextLlm.maxTokens) {
1487
+ nextLlm.maxTokens = llm.maxTokens;
1488
+ llmChanged = true;
1489
+ }
1490
+
1491
+ if (llmChanged) {
1492
+ this.config.llm = nextLlm;
1493
+ this.llm = createLLMAdapter(this.config.llm);
1494
+ this.configureLLMAdapter(this.llm);
1495
+ }
1496
+ }
1497
+
1498
+ if (Array.isArray(cfg.venues)) {
1499
+ const selectedVenues = cfg.venues.filter((venue): venue is string => typeof venue === 'string');
1500
+ this.config.venues = this.buildRuntimeVenuesFromSelection(selectedVenues);
1501
+ }
1502
+
1503
+ const nextStrategyConfig = this.extractStrategyConfigFromAgentConfig(cfg);
1504
+ const nextStrategy = await loadStrategy(nextStrategyConfig);
1505
+ this.config.strategy = nextStrategyConfig;
1506
+ this.strategy = nextStrategy;
1507
+ await this.applyExecutionMode();
1508
+
1509
+ if (previousMode === 'trading' || previousMode === 'paper') {
1510
+ this.startTrading();
1511
+ } else if (previousMode === 'paused') {
1512
+ this.modeBeforePause = this.config.trading.mode === 'paper' ? 'paper' : 'trading';
1513
+ this.mode = 'paused';
1514
+ } else {
1515
+ this.mode = 'idle';
1516
+ this.modeBeforePause = null;
1517
+ }
1518
+
1186
1519
  getLogger().info('runtime', 'Config reloaded from API');
1187
1520
  this.sendStatus();
1188
1521
  }
@@ -1359,19 +1692,19 @@ export class AgentRuntime {
1359
1692
  },
1360
1693
  venues: {
1361
1694
  hyperliquid: {
1362
- enabled: !!this.hlClient,
1695
+ enabled: this.config.venues?.hyperliquid_perp?.enabled === true,
1363
1696
  trading: !!this.hlOrders && (this.mode === 'trading' || this.mode === 'paused'),
1364
1697
  },
1365
1698
  polymarket: {
1366
- enabled: !!this.pmClient,
1699
+ enabled: this.config.venues?.polymarket?.enabled === true,
1367
1700
  trading: !!this.pmOrders && (this.mode === 'trading' || this.mode === 'paused'),
1368
1701
  },
1369
1702
  spot: {
1370
- enabled: !!this.spotManager,
1703
+ enabled: this.config.venues?.spot?.enabled === true,
1371
1704
  trading: !!this.spotManager && (this.mode === 'trading' || this.mode === 'paused'),
1372
1705
  },
1373
1706
  bridge: {
1374
- enabled: !!this.bridgeManager,
1707
+ enabled: this.config.venues?.bridge?.enabled === true,
1375
1708
  },
1376
1709
  },
1377
1710
  };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Secret scrubbing utility — defense-in-depth protection against LLM responses
3
+ * or log messages accidentally containing API keys, private keys, or tokens.
4
+ *
5
+ * Applied to:
6
+ * - LLM response content before parsing (strategy/loader.ts)
7
+ * - Strategy log output (runtime.ts context.log)
8
+ */
9
+
10
+ const SECRET_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
11
+ // OpenAI API keys
12
+ { pattern: /sk-[a-zA-Z0-9]{20,}/g, label: '[REDACTED:openai-key]' },
13
+ // Anthropic API keys
14
+ { pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/g, label: '[REDACTED:anthropic-key]' },
15
+ // Google API keys
16
+ { pattern: /AIza[a-zA-Z0-9_-]{35}/g, label: '[REDACTED:google-key]' },
17
+ // Private keys (64 hex chars after 0x)
18
+ { pattern: /0x[a-fA-F0-9]{64}/g, label: '[REDACTED:private-key]' },
19
+ // Agent tokens
20
+ { pattern: /exg_[a-fA-F0-9]{64}/g, label: '[REDACTED:agent-token]' },
21
+ // Bootstrap tokens
22
+ { pattern: /exb_[a-fA-F0-9]{64}/g, label: '[REDACTED:bootstrap-token]' },
23
+ // Generic long bearer tokens (base64-ish, 40+ chars)
24
+ { pattern: /Bearer\s+[A-Za-z0-9_-]{40,}/g, label: '[REDACTED:bearer-token]' },
25
+ ];
26
+
27
+ /**
28
+ * Scrub known secret patterns from text. Returns the scrubbed string.
29
+ * Safe to call on any text — if no patterns match, returns the original unchanged.
30
+ */
31
+ export function scrubSecrets(text: string): string {
32
+ let result = text;
33
+ for (const { pattern, label } of SECRET_PATTERNS) {
34
+ // Reset lastIndex for global regexes
35
+ pattern.lastIndex = 0;
36
+ result = result.replace(pattern, label);
37
+ }
38
+ return result;
39
+ }