import dotenv from "dotenv"; import { z } from "zod"; import { loadAgentModelConfig, type AgentModelConfig } from "./chat/modelConfig.js"; import { DEFAULT_SESSION_WORKSPACE_ROOT } from "./runtime/conversationWorkspace.js"; // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 dotenv.config({ path: ".local.env", override: false }); const optionalString = () => z.preprocess( (value) => { if (typeof value !== "string") { return value; } const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }, z.string().optional(), ); const optionalBoolean = (defaultValue: boolean) => z.preprocess((value) => { if (typeof value !== "string") { return value; } const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) { return true; } if (["0", "false", "no", "off"].includes(normalized)) { return false; } return value; }, z.boolean().default(defaultValue)); // 统一在启动时解析环境变量,避免业务代码里散落字符串默认值。 const envSchema = z .object({ // 运行环境标识,如 development / production。 NODE_ENV: z.string().default("development"), // HTTP 服务监听端口。 PORT: z.coerce.number().int().positive().default(8787), // HTTP 服务监听地址。 HOST: z.string().default("127.0.0.1"), // Product Server base URL used to verify browser identity and project scope. TJWATER_SERVER_BASE_URL: z.string().url().default("http://127.0.0.1:8000"), // 工况任务调用 Server 的最长等待时间,必须短于两次租约心跳之间的间隔。 TJWATER_SERVER_TIMEOUT_MS: z.coerce.number().int().positive().max(30_000).default(10_000), TJWATER_MCP_ENABLED: optionalBoolean(false), TJWATER_MCP_ISSUER_URI: optionalString(), TJWATER_MCP_DEV_HTTP_ISSUER_URI: optionalString(), TJWATER_MCP_RESOURCE_URI: optionalString(), TJWATER_MCP_AUDIENCE: z.string().default("tjwater-mcp"), TJWATER_MCP_CLIENT_ID: z.string().default("tjwater-agent-mcp"), TJWATER_MCP_CLIENT_SECRET: optionalString(), // Pino 日志级别。 LOG_LEVEL: z.string().default("info"), // LLM 工具/技能调用审计日志路径。 LLM_REQUEST_AUDIT_LOG_PATH: z .string() .default("./logs/llm-request-audit.log"), // 内部工具桥鉴权 token;embedded 可自动生成,client 模式必须与外部 OpenCode 显式共享。 AGENT_INTERNAL_TOKEN: optionalString(), // 每个 OpenCode 会话独占的文件工作目录;其他 data/logs 仍受保护。 CONVERSATION_WORKSPACE_ROOT: z.string().default(DEFAULT_SESSION_WORKSPACE_ROOT), SANDBOX_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().max(16 * 1024 * 1024).default(1024 * 1024), // OpenCode 2 CLI 命令或绝对路径。 OPENCODE_COMMAND: z.string().default("opencode2"), // 外部 OpenCode 2 Server 的密码;embedded 模式自动管理。 OPENCODE_CLIENT_PASSWORD: optionalString(), // embedded 启动私有 CLI 子进程;client 连接现有 V2 server。 OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), // embedded opencode server 的监听地址。 OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), // embedded opencode server 的监听端口;0 表示由系统动态分配。 OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0), // OpenCode 2 启动超时时间(毫秒)。 OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), // 可选的统一模型配置文件;未设置时读取仓库中的 agent-models.json。 AGENT_MODEL_CONFIG_PATH: optionalString(), // 平滑 token 输出,避免上游模型大块/抖动 token 直接传到前端。 AGENT_TOKEN_SMOOTHING_ENABLED: optionalBoolean(true), // 平滑输出每段之间的间隔(毫秒)。 AGENT_TOKEN_SMOOTHING_DELAY_MS: z.coerce .number() .int() .min(0) .default(20), // 平滑输出分词 locale,中文默认使用 zh。 AGENT_TOKEN_SMOOTHING_LOCALE: z.string().default("zh"), // opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。 OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), // client 模式下,目标 opencode server 的基础地址。 OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), // 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。 OPENCODE_BASE_URL: z.string().url().optional(), // memory 持久化存储目录。 MEMORY_STORAGE_DIR: z.string().default("./data/memory"), // 持久化文件写入前保留备份版本的目录。 PERSISTENCE_BACKUP_DIR: z.string().default("./data/backup"), // 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。 MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), // 旧 transcript 迁移来源;新记录位于用户会话工作空间的 chat/。 SESSION_TRANSCRIPT_STORAGE_DIR: z.string().default("./data/session-transcripts"), // session metadata 持久化目录。 SESSION_METADATA_STORAGE_DIR: z.string().default("./data/session-metadata"), // 旧 UI state 迁移来源;新完整聊天状态位于会话工作空间的 chat/。 SESSION_UI_STATE_STORAGE_DIR: z.string().default("./data/session-ui-states"), // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。 SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION: z.coerce .number() .int() .positive() .default(120), // session_search 工具默认返回的最大命中数。 SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8), // session_search 查询文本最大长度。 SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240), // 旧 learning 进度迁移来源;新进度位于会话 chat/learning.json。 SESSION_LEARNING_STATE_STORAGE_DIR: z.string().default("./data/session-learning-state"), // learning audit 日志路径。 LEARNING_AUDIT_LOG_PATH: z .string() .default("./logs/learning-audit.log"), // learning gate 的最小 turn 冷却间隔;这是运行时节流,不参与内容判断。 LEARNING_GATE_TURN_COOLDOWN: z.coerce.number().int().positive().default(2), // gate 结果被提升为 review 前的最低置信度。 LEARNING_GATE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.65), // review prompt 最多携带多少轮最近 transcript。 LEARNING_REVIEW_MAX_RECENT_TURNS: z.coerce.number().int().positive().default(8), // review proposal 的最低置信度阈值。 LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), }) .superRefine((env, ctx) => { if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["OPENCODE_CLIENT_BASE_URL"], message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client", }); } if (env.OPENCODE_MODE === "client" && !env.AGENT_INTERNAL_TOKEN) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["AGENT_INTERNAL_TOKEN"], message: "AGENT_INTERNAL_TOKEN is required in client mode and must also be configured on the external OpenCode process", }); } }); export type AppConfig = z.infer & { MODEL_CONFIG: AgentModelConfig; OPENCODE_MODEL: string; OPERATIONAL_TASK_MODEL: string; LEARNING_GATE_MODEL: string; }; const normalizedEnv = { ...process.env, OPENCODE_MODE: process.env.OPENCODE_MODE ?? (process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL ? "client" : "embedded"), OPENCODE_CLIENT_BASE_URL: process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL, }; const environment = envSchema.parse(normalizedEnv); const modelConfiguration = loadAgentModelConfig(environment.AGENT_MODEL_CONFIG_PATH); export const config: AppConfig = { ...environment, MODEL_CONFIG: modelConfiguration, OPENCODE_MODEL: modelConfiguration.defaults.chat, OPERATIONAL_TASK_MODEL: modelConfiguration.defaults.task, LEARNING_GATE_MODEL: modelConfiguration.defaults.learning_gate, };