import { join } from 'node:path' import { type BudgetConfig, DEFAULT_BUDGET } from '../shared/budget.ts' import { envInt, envOpt, envStr } from '../shared/env.ts' /** * Agent runtime configuration (spec: daily budget lives in deployment * configuration outside the mutable repository; env with sane defaults here). */ export interface AgentConfig { agentDir: string workDir: string resourceDir: string sessionFile: string serviceUrl: string readyAddr: string telegram: { token?: string | undefined chatId?: number | undefined oliverId?: string | undefined apiBase?: string | undefined } vcs: { gitUrl?: string | undefined; branch: string } zai: { apiKey?: string | undefined baseUrl: string /** multimodal coding model used by zai_analyze_media and the resident session (pinned deployment config) */ analysisModel: string /** resident session thinking level; Pi default 'medium' made explicit (pinned deployment config) */ thinkingLevel: ThinkingLevel mcp: { enabled: boolean; serverCommand: string[] } } deployApiUrl?: string | undefined luci: { baseUrl?: string | undefined; repo?: string | undefined } budget: BudgetConfig revision?: string | undefined agentDigest?: string | undefined maxTurnMinutes: number } export type SessionMode = 'real' | 'fake' /** Pi thinking levels; the model clamps unsupported ones (see thinkingLevelMap) */ export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' const THINKING_LEVELS: readonly ThinkingLevel[] = [ 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', ] function thinkingLevelFromEnv(env: NodeJS.ProcessEnv): ThinkingLevel { const raw = env.ZAI_THINKING_LEVEL ?? 'medium' if (!(THINKING_LEVELS as readonly string[]).includes(raw)) { throw new Error(`ZAI_THINKING_LEVEL must be one of ${THINKING_LEVELS.join('|')}; got '${raw}'`) } return raw as ThinkingLevel } /** * Session selection: explicit fake wins; explicit real demands a key; * unset falls back by key presence so `ZAI_API_KEY=... mise run dev` * gives the real session with zero extra flags (spec: fake is dev-only). */ export function resolveSessionMode(env: NodeJS.ProcessEnv = process.env): SessionMode { const explicit = env.WILL_SESSION_MODE if (explicit === 'fake') return 'fake' if (explicit === 'real') { if (!env.ZAI_API_KEY) throw new Error('WILL_SESSION_MODE=real requires ZAI_API_KEY') return 'real' } return env.ZAI_API_KEY ? 'real' : 'fake' } export function loadAgentConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig { const agentDir = envStr('WILL_AGENT_DIR', '/srv/will-agent') const resourceDir = envStr('WILL_RESOURCE_DIR', '/app/repo') const budgetFileCap = env.WILL_BUDGET_DAILY_CAP const budgetFraction = env.WILL_BUDGET_RECOVERY_FRACTION const tgChat = env.WILL_TELEGRAM_CHAT_ID return { agentDir, workDir: envOpt('WILL_WORK_DIR') ?? join(agentDir, 'work'), resourceDir, sessionFile: join(agentDir, 'session.jsonl'), serviceUrl: envStr('WILL_SERVICE_INTERNAL_URL', 'http://127.0.0.1:3001'), readyAddr: envStr('WILL_AGENT_READY_ADDR', '127.0.0.1:8081'), telegram: { token: envOpt('TELEGRAM_BOT_TOKEN') ?? envOpt('WILL_TELEGRAM_TOKEN'), chatId: tgChat !== undefined && tgChat !== '' ? Number.parseInt(tgChat, 10) : undefined, oliverId: envOpt('WILL_TELEGRAM_OLIVER_ID'), apiBase: envOpt('WILL_TELEGRAM_API_BASE'), }, vcs: { gitUrl: envOpt('WILL_VCS_GIT_URL'), branch: envStr('WILL_VCS_DEFAULT_BRANCH', 'trunk'), }, zai: { apiKey: envOpt('ZAI_API_KEY'), baseUrl: envStr('ZAI_BASE_URL', 'https://api.z.ai/api'), analysisModel: envStr('ZAI_ANALYSIS_MODEL', 'glm-5.3-flash'), thinkingLevel: thinkingLevelFromEnv(env), mcp: { enabled: env.WILL_ZAI_MCP === '1', serverCommand: [ 'node', envStr( 'WILL_ZAI_MCP_SERVER', join('node_modules', '@z_ai', 'mcp-server', 'build', 'index.js'), ), ], }, }, deployApiUrl: envOpt('WILL_DEPLOY_API_URL'), luci: { baseUrl: envOpt('WILL_LUCI_BASE_URL'), repo: envOpt('WILL_LUCI_REPO') }, budget: { dailyCapCalls: budgetFileCap !== undefined && budgetFileCap !== '' ? Number.parseInt(budgetFileCap, 10) : DEFAULT_BUDGET.dailyCapCalls, recoveryReserveFraction: budgetFraction !== undefined && budgetFraction !== '' ? Number.parseFloat(budgetFraction) : DEFAULT_BUDGET.recoveryReserveFraction, }, revision: envOpt('WILL_REVISION'), agentDigest: envOpt('WILL_AGENT_DIGEST'), maxTurnMinutes: envInt('WILL_TURN_DEADLINE_MINUTES', 30), } }