import os from 'os'; import path from 'path'; import type { MoaConfig, MoaModelConfig } from '../moa.js'; import type { AgentType } from '../types.js'; import { getEnv, listConfiguredEnvKeys } from '../env.js'; import type { AppConfig, RoleModelConfig } from './schema.js'; const CANONICAL_DISCORD_CHANNEL_TOKEN_KEYS = new Set([ 'DISCORD_OWNER_BOT_TOKEN', 'DISCORD_REVIEWER_BOT_TOKEN', 'DISCORD_ARBITER_BOT_TOKEN', ]); const CANONICAL_SESSION_COMMAND_KEYS = new Set([ 'SESSION_COMMAND_ALLOWED_SENDERS', ]); function readText(key: string): string | undefined { return getEnv(key); } function readNonEmptyText(key: string): string | undefined { const value = getEnv(key); return value === '' ? undefined : value; } function readBoolean(key: string, fallback = false): boolean { const value = readText(key); if (value == null || value === '') return fallback; return value === 'true'; } function readBooleanUnlessFalse(key: string, fallback = true): boolean { const value = readText(key); if (value == null || value === '') return fallback; return value !== 'false'; } function readInteger(key: string, fallback: number): number { const value = readNonEmptyText(key); if (value == null) return fallback; const parsed = parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; } function readIntegerAtLeast( key: string, fallback: number, minimum: number, ): number { return Math.max(minimum, readInteger(key, fallback) || fallback); } function readPercent(key: string, fallback: number): number { const value = readInteger(key, fallback); return Math.min(100, Math.max(0, value)); } function readAgentType( key: string, fallback?: AgentType, ): AgentType | undefined { const value = readText(key); if (value === 'codex' || value === 'claude-code') return value; return fallback; } function buildRoleModelConfig(envPrefix: string): RoleModelConfig { return { model: readNonEmptyText(`${envPrefix}_MODEL`), effort: readNonEmptyText(`${envPrefix}_EFFORT`), fallbackEnabled: readText(`${envPrefix}_FALLBACK_ENABLED`) !== 'false', }; } function parseMoaReferenceModels(): MoaModelConfig[] { const names = (readText('MOA_REF_MODELS') ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean); return names .map((name) => { const prefix = `MOA_${name.toUpperCase()}`; const model = readNonEmptyText(`${prefix}_MODEL`) ?? ''; const baseUrl = readNonEmptyText(`${prefix}_BASE_URL`) ?? ''; const apiKey = readNonEmptyText(`${prefix}_API_KEY`) ?? ''; if (!model || !baseUrl || !apiKey) return null; const rawFormat = readText(`${prefix}_API_FORMAT`) ?? ''; const apiFormat: 'openai' | 'anthropic' = rawFormat === 'anthropic' ? 'anthropic' : 'openai'; return { name, model, baseUrl, apiKey, apiFormat }; }) .filter((value): value is MoaModelConfig => value !== null); } function buildMoaConfig(): MoaConfig { const referenceModels = parseMoaReferenceModels(); return { enabled: readText('MOA_ENABLED') === 'true' && referenceModels.length > 0, referenceModels, }; } function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function normalizeServiceId( serviceId: string, codexMainServiceId: string, ): string { return serviceId === 'codex' ? codexMainServiceId : serviceId; } function assertNoLegacyEnvAliasesConfigured(): void { const configuredAliases = listConfiguredEnvKeys().filter((key) => { if (key.startsWith('DISCORD_') && key.endsWith('_BOT_TOKEN')) { return !CANONICAL_DISCORD_CHANNEL_TOKEN_KEYS.has(key); } if (key.startsWith('SESSION_COMMAND_')) { return !CANONICAL_SESSION_COMMAND_KEYS.has(key); } return false; }); if (configuredAliases.length === 0) { return; } throw new Error( `Legacy env aliases are no longer supported; remove or rename (${configuredAliases.join(', ')}) to the canonical keys DISCORD_OWNER_BOT_TOKEN, DISCORD_REVIEWER_BOT_TOKEN, DISCORD_ARBITER_BOT_TOKEN, SESSION_COMMAND_ALLOWED_SENDERS`, ); } function buildPathsConfig( projectRoot: string, homeDir: string, ): AppConfig['paths'] { return { projectRoot, homeDir, senderAllowlistPath: path.join( homeDir, '.config', 'ejclaw', 'sender-allowlist.json', ), storeDir: path.resolve( readNonEmptyText('EJCLAW_STORE_DIR') ?? path.join(projectRoot, 'store'), ), groupsDir: path.resolve( readNonEmptyText('EJCLAW_GROUPS_DIR') ?? path.join(projectRoot, 'groups'), ), dataDir: path.resolve( readNonEmptyText('EJCLAW_DATA_DIR') ?? path.join(projectRoot, 'data'), ), cacheDir: path.resolve( readNonEmptyText('EJCLAW_CACHE_DIR') ?? path.join(projectRoot, 'cache'), ), }; } export function loadConfig(): AppConfig { assertNoLegacyEnvAliasesConfigured(); const assistantName = readText('ASSISTANT_NAME') ?? 'Andy'; const assistantSlug = assistantName.trim().toLowerCase(); const claudeServiceId = readText('CLAUDE_SERVICE_ID') ?? 'claude'; const codexMainServiceId = readText('CODEX_MAIN_SERVICE_ID') ?? 'codex-main'; const codexReviewServiceId = readText('CODEX_REVIEW_SERVICE_ID') ?? 'codex-review'; const serviceId = readText('SERVICE_ID') ?? assistantSlug; const serviceSessionScope = normalizeServiceId(serviceId, codexMainServiceId); const ownerAgentType = readAgentType('OWNER_AGENT_TYPE', 'codex') ?? 'codex'; const reviewerAgentType = readAgentType('REVIEWER_AGENT_TYPE', 'claude-code') ?? 'claude-code'; const arbiterAgentType = readAgentType('ARBITER_AGENT_TYPE'); const projectRoot = process.cwd(); const homeDir = readNonEmptyText('HOME') ?? os.homedir(); const rawMaxRoundTrips = readText('PAIRED_MAX_ROUND_TRIPS'); const maxRoundTrips = rawMaxRoundTrips === '0' ? Infinity : readInteger('PAIRED_MAX_ROUND_TRIPS', 1000); return { assistant: { name: assistantName, hasOwnNumber: readBoolean('ASSISTANT_HAS_OWN_NUMBER', false), slug: assistantSlug, triggerPattern: new RegExp(`^@${escapeRegex(assistantName)}\\b`, 'i'), }, service: { id: serviceId, sessionScope: serviceSessionScope, claudeId: claudeServiceId, codexMainId: codexMainServiceId, codexReviewId: codexReviewServiceId, }, paths: buildPathsConfig(projectRoot, homeDir), runtime: { pollInterval: 2000, schedulerPollInterval: 60000, failoverMinDurationMs: 3 * 60 * 60 * 1000, agentTimeout: readInteger('AGENT_TIMEOUT', 1_800_000), agentMaxOutputSize: readInteger('AGENT_MAX_OUTPUT_SIZE', 10_485_760), ipcPollInterval: 1000, idleTimeout: readInteger('IDLE_TIMEOUT', 1_800_000), maxConcurrentAgents: readIntegerAtLeast('MAX_CONCURRENT_AGENTS', 5, 1), recoveryConcurrentAgents: readInteger('RECOVERY_CONCURRENT_AGENTS', 3), recoveryStaggerMs: readInteger('RECOVERY_STAGGER_MS', 2000), recoveryDurationMs: readInteger('RECOVERY_DURATION_MS', 60000), }, logging: { level: readText('LOG_LEVEL') ?? 'info', isTestEnv: readText('VITEST') === 'true' || readText('NODE_ENV') === 'test', }, paired: { ownerAgentType, reviewerAgentType, reviewerServiceIdForType: reviewerAgentType === 'claude-code' ? claudeServiceId : codexReviewServiceId, arbiterAgentType, arbiterServiceId: arbiterAgentType ? (readText('ARBITER_SERVICE_ID') ?? codexReviewServiceId) : null, carryForwardLatestOwnerFinal: readBoolean( 'PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL', false, ), forceFreshClaudeReviewerSessionInUnsafeHostMode: readBoolean( 'PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION', false, ), agentLanguage: readText('AGENT_LANGUAGE') ?? '', arbiterDeadlockThreshold: readInteger('ARBITER_DEADLOCK_THRESHOLD', 2), maxRoundTrips, }, models: { owner: buildRoleModelConfig('OWNER'), reviewer: buildRoleModelConfig('REVIEWER'), arbiter: buildRoleModelConfig('ARBITER'), }, providers: { claudeDefaultModel: readText('CLAUDE_MODEL') ?? 'claude', codexDefaultModel: readText('CODEX_MODEL') ?? 'codex', }, moa: buildMoaConfig(), status: { channelId: readText('STATUS_CHANNEL_ID') ?? '', updateInterval: 10000, usageUpdateInterval: 300000, showRooms: readBooleanUnlessFalse('STATUS_SHOW_ROOMS', true), showRoomDetails: readBooleanUnlessFalse('STATUS_SHOW_ROOM_DETAILS', true), usageDashboardEnabled: readText('USAGE_DASHBOARD') === 'true', timezone: readText('TZ') ?? Intl.DateTimeFormat().resolvedOptions().timeZone, }, webDashboard: { enabled: readBoolean('WEB_DASHBOARD_ENABLED', false), host: readNonEmptyText('WEB_DASHBOARD_HOST') ?? '127.0.0.1', port: readIntegerAtLeast('WEB_DASHBOARD_PORT', 8734, 1), staticDir: path.resolve( readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ?? path.join(projectRoot, 'apps', 'dashboard', 'dist'), ), }, codexWarmup: { enabled: readBoolean('CODEX_WARMUP_ENABLED', false), prompt: readNonEmptyText('CODEX_WARMUP_PROMPT') ?? 'Reply exactly OK. Do not run tools.', model: readNonEmptyText('CODEX_WARMUP_MODEL') ?? readNonEmptyText('CODEX_MODEL') ?? 'codex', intervalMs: readIntegerAtLeast('CODEX_WARMUP_INTERVAL_MS', 300000, 60000), minIntervalMs: readIntegerAtLeast( 'CODEX_WARMUP_MIN_INTERVAL_MS', 18300000, 60000, ), staggerMs: Math.max(0, readInteger('CODEX_WARMUP_STAGGER_MS', 1800000)), maxUsagePct: readPercent('CODEX_WARMUP_MAX_USAGE_PCT', 0), maxD7UsagePct: readPercent('CODEX_WARMUP_MAX_D7_USAGE_PCT', 0), commandTimeoutMs: readIntegerAtLeast( 'CODEX_WARMUP_COMMAND_TIMEOUT_MS', 120000, 10000, ), failureCooldownMs: readIntegerAtLeast( 'CODEX_WARMUP_FAILURE_COOLDOWN_MS', 21600000, 60000, ), maxConsecutiveFailures: readIntegerAtLeast( 'CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES', 2, 1, ), }, sessionCommands: { allowedSenders: new Set( (readText('SESSION_COMMAND_ALLOWED_SENDERS') ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean), ), }, deltaHandoff: { enabled: readText('DELTA_HANDOFF_ENABLED') === 'true', canaryGroups: new Set( (readText('DELTA_HANDOFF_CANARY_GROUPS') ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean), ), }, }; }