Luigit
repositories / will

will

owned by admin

src/agent/config.ts

Raw
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),
  }
}