{"repo": "anxelswanz/astraea-agent", "files": {"src/config.ts": {"e10db0244ace6c65c1c4a17bc9c4f8e83b6a7ff6": {"t": 1785745562.268583, "n": 21241, "h": "04c7c08edecfe745", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\nimport { unsetSessionEffort } from './state/reasoningEffort'\nimport { loadCodexCredentials } from './auth/codexAuth'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai' | 'codex' | 'custom'\n\n/** Wire protocol for PROVIDER=custom (and /login Custom). */\nexport type CustomApiStyle = 'openai' | 'anthropic'\n\nfunction detectCustomApiStyle(): CustomApiStyle {\n const raw = (process.env.CUSTOM_API_STYLE ?? process.env.CUSTOM_CONNECTION_STYLE ?? 'openai')\n .trim()\n .toLowerCase()\n if (raw === 'anthropic' || raw === 'claude' || raw === 'messages') return 'anthropic'\n return 'openai'\n}\n\n/** Strip trailing slashes so SDK path joins stay stable. */\nexport function normalizeBaseUrl(url: string): string {\n return url.trim().replace(/\\/+$/, '')\n}\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'codex') return 'codex'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n if (raw === 'custom') return 'custom'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n\n // Codex (ChatGPT subscription) — uses OAuth + the Responses API, no apiKey (credentials live in ~/.astraea/auth.json).\n codex: {\n model: process.env.CODEX_MODEL ?? 'gpt-5.4',\n baseUrl: 'https://chatgpt.com/backend-api',\n maxTokens: maxTokensFrom('CODEX_MAX_TOKENS', 32000),\n contextWindow: contextWindowFrom('CODEX_CONTEXT_WINDOW', 256_000),\n },\n\n // Custom OpenAI- or Anthropic-compatible gateway (right.codes, Azure proxies, self-hosted, …).\n // Set PROVIDER=custom plus CUSTOM_BASE_URL / CUSTOM_API_KEY / CUSTOM_MODEL / CUSTOM_API_STYLE.\n custom: {\n apiKey: process.env.CUSTOM_API_KEY ?? '',\n model: process.env.CUSTOM_MODEL ?? '',\n baseUrl: normalizeBaseUrl(process.env.CUSTOM_BASE_URL ?? ''),\n apiStyle: detectCustomApiStyle(),\n maxTokens: maxTokensFrom('CUSTOM_MAX_TOKENS', 16384),\n contextWindow: contextWindowFrom('CUSTOM_CONTEXT_WINDOW', 128_000),\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n case 'codex': return config.codex.contextWindow\n case 'custom': return config.custom.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n case 'codex': return config.codex.maxTokens\n case 'custom': return config.custom.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n/** Active provider's model id (for CLI / UI / traces). */\nexport function activeModel(): string {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.model\n case 'kimi': return config.kimi.model\n case 'ollama': return config.ollama.model\n case 'openai': return config.openai.model\n case 'codex': return config.codex.model\n case 'custom': return config.custom.model\n default: return config.anthropic.model\n }\n}\n\n/** Active provider's HTTP base URL when one exists (for /model display). */\nexport function activeBaseUrl(): string {\n switch (config.provider) {\n case 'ollama': return config.ollama.baseUrl\n case 'openai': return config.openai.baseUrl\n case 'codex': return `${config.codex.baseUrl}/codex/responses`\n case 'deepseek': return config.deepseek.baseUrl\n case 'kimi': return config.kimi.baseUrl\n case 'custom': return config.custom.baseUrl || '(not set)'\n default: return 'https://api.anthropic.com'\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n case 'codex': return loadCodexCredentials() !== null\n case 'custom':\n return !!config.custom.apiKey && !!config.custom.baseUrl && !!config.custom.model\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'codex' && loadCodexCredentials() === null) {\n console.error('Error: not logged in to Codex — run /login and choose OpenAI Codex')\n process.exit(1)\n }\n if (config.provider === 'custom') {\n if (!config.custom.baseUrl) {\n console.error('Error: CUSTOM_BASE_URL is not set (or run /login → Custom)')\n process.exit(1)\n }\n if (!config.custom.apiKey) {\n console.error('Error: CUSTOM_API_KEY is not set (or run /login → Custom)')\n process.exit(1)\n }\n if (!config.custom.model) {\n console.error('Error: CUSTOM_MODEL is not set (or run /login → Custom)')\n process.exit(1)\n }\n }\n}\n\nexport type ProviderUpdateOptions = {\n /** Required for provider=custom; ignored otherwise. */\n baseUrl?: string\n /** Required for provider=custom; defaults to openai. */\n apiStyle?: CustomApiStyle\n}\n\nexport function updateProviderConfig(\n provider: Provider,\n model: string,\n apiKey: string,\n options: ProviderUpdateOptions = {},\n): boolean {\n const previousProvider = config.provider\n const previousModel = (config as unknown as Record)[provider]?.model\n const modelSelectionChanged = previousProvider !== provider || previousModel !== model\n\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n case 'codex':\n // Credentials live in auth.json; the apiKey arg is meaningless for codex, so we only record the chosen model.\n config.codex.model = model\n break\n case 'custom': {\n config.custom.apiKey = apiKey\n config.custom.model = model\n if (options.baseUrl !== undefined) config.custom.baseUrl = normalizeBaseUrl(options.baseUrl)\n if (options.apiStyle !== undefined) config.custom.apiStyle = options.apiStyle\n break\n }\n }\n if (modelSelectionChanged) {\n unsetSessionEffort()\n delete process.env.ASTRAEA_REASONING_EFFORT\n }\n return modelSelectionChanged\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n '# ─── Codex (ChatGPT subscription, OAuth) ──────────────────',\n '# Credentials are written to ~/.astraea/auth.json via /login; no secret here.',\n `# PROVIDER=codex`,\n `CODEX_MODEL=${config.codex.model}`,\n '',\n '# ─── Custom gateway (OpenAI- or Anthropic-compatible) ───',\n `CUSTOM_API_KEY=${config.custom.apiKey}`,\n `CUSTOM_BASE_URL=${config.custom.baseUrl}`,\n `CUSTOM_MODEL=${config.custom.model}`,\n `CUSTOM_API_STYLE=${config.custom.apiStyle}`,\n `# CUSTOM_MAX_TOKENS=${config.custom.maxTokens}`,\n `# CUSTOM_CONTEXT_WINDOW=${config.custom.contextWindow}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n\nfunction loginEnvUpdates(): Record {\n const updates: Record = { PROVIDER: config.provider }\n switch (config.provider) {\n case 'anthropic':\n updates.ANTHROPIC_API_KEY = config.anthropic.apiKey\n updates.ANTHROPIC_MODEL = config.anthropic.model\n break\n case 'deepseek':\n updates.DEEPSEEK_API_KEY = config.deepseek.apiKey\n updates.DEEPSEEK_MODEL = config.deepseek.model\n break\n case 'kimi':\n updates.KIMI_API_KEY = config.kimi.apiKey\n updates.KIMI_MODEL = config.kimi.model\n updates.KIMI_BASE_URL = config.kimi.baseUrl\n break\n case 'openai':\n updates.OPENAI_API_KEY = config.openai.apiKey\n updates.OPENAI_MODEL = config.openai.model\n updates.OPENAI_BASE_URL = config.openai.baseUrl\n break\n case 'ollama':\n updates.OLLAMA_MODEL = config.ollama.model\n updates.OLLAMA_BASE_URL = config.ollama.baseUrl\n break\n case 'codex':\n // No secret written to .env — the token lives in ~/.astraea/auth.json; we only record provider + model.\n updates.CODEX_MODEL = config.codex.model\n break\n case 'custom':\n updates.CUSTOM_API_KEY = config.custom.apiKey\n updates.CUSTOM_BASE_URL = config.custom.baseUrl\n updates.CUSTOM_MODEL = config.custom.model\n updates.CUSTOM_API_STYLE = config.custom.apiStyle\n break\n }\n return updates\n}\n\nexport async function saveLoginConfigToEnvFiles(\n globalDestination: string = globalEnvPath,\n projectDestination: string = envPath,\n): Promise {\n await saveConfigToEnv(globalDestination)\n if (existsSync(projectDestination)) {\n await mergeEnvFile(projectDestination, loginEnvUpdates())\n }\n}\n"}, "5022a47abaab93620a533f1170b3ebf59599e8b6": {"t": 1785754237.1352677, "n": 21241, "h": "04c7c08edecfe745", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\nimport { unsetSessionEffort } from './state/reasoningEffort'\nimport { loadCodexCredentials } from './auth/codexAuth'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai' | 'codex' | 'custom'\n\n/** Wire protocol for PROVIDER=custom (and /login Custom). */\nexport type CustomApiStyle = 'openai' | 'anthropic'\n\nfunction detectCustomApiStyle(): CustomApiStyle {\n const raw = (process.env.CUSTOM_API_STYLE ?? process.env.CUSTOM_CONNECTION_STYLE ?? 'openai')\n .trim()\n .toLowerCase()\n if (raw === 'anthropic' || raw === 'claude' || raw === 'messages') return 'anthropic'\n return 'openai'\n}\n\n/** Strip trailing slashes so SDK path joins stay stable. */\nexport function normalizeBaseUrl(url: string): string {\n return url.trim().replace(/\\/+$/, '')\n}\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'codex') return 'codex'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n if (raw === 'custom') return 'custom'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n\n // Codex (ChatGPT subscription) — uses OAuth + the Responses API, no apiKey (credentials live in ~/.astraea/auth.json).\n codex: {\n model: process.env.CODEX_MODEL ?? 'gpt-5.4',\n baseUrl: 'https://chatgpt.com/backend-api',\n maxTokens: maxTokensFrom('CODEX_MAX_TOKENS', 32000),\n contextWindow: contextWindowFrom('CODEX_CONTEXT_WINDOW', 256_000),\n },\n\n // Custom OpenAI- or Anthropic-compatible gateway (right.codes, Azure proxies, self-hosted, …).\n // Set PROVIDER=custom plus CUSTOM_BASE_URL / CUSTOM_API_KEY / CUSTOM_MODEL / CUSTOM_API_STYLE.\n custom: {\n apiKey: process.env.CUSTOM_API_KEY ?? '',\n model: process.env.CUSTOM_MODEL ?? '',\n baseUrl: normalizeBaseUrl(process.env.CUSTOM_BASE_URL ?? ''),\n apiStyle: detectCustomApiStyle(),\n maxTokens: maxTokensFrom('CUSTOM_MAX_TOKENS', 16384),\n contextWindow: contextWindowFrom('CUSTOM_CONTEXT_WINDOW', 128_000),\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n case 'codex': return config.codex.contextWindow\n case 'custom': return config.custom.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n case 'codex': return config.codex.maxTokens\n case 'custom': return config.custom.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n/** Active provider's model id (for CLI / UI / traces). */\nexport function activeModel(): string {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.model\n case 'kimi': return config.kimi.model\n case 'ollama': return config.ollama.model\n case 'openai': return config.openai.model\n case 'codex': return config.codex.model\n case 'custom': return config.custom.model\n default: return config.anthropic.model\n }\n}\n\n/** Active provider's HTTP base URL when one exists (for /model display). */\nexport function activeBaseUrl(): string {\n switch (config.provider) {\n case 'ollama': return config.ollama.baseUrl\n case 'openai': return config.openai.baseUrl\n case 'codex': return `${config.codex.baseUrl}/codex/responses`\n case 'deepseek': return config.deepseek.baseUrl\n case 'kimi': return config.kimi.baseUrl\n case 'custom': return config.custom.baseUrl || '(not set)'\n default: return 'https://api.anthropic.com'\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n case 'codex': return loadCodexCredentials() !== null\n case 'custom':\n return !!config.custom.apiKey && !!config.custom.baseUrl && !!config.custom.model\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'codex' && loadCodexCredentials() === null) {\n console.error('Error: not logged in to Codex — run /login and choose OpenAI Codex')\n process.exit(1)\n }\n if (config.provider === 'custom') {\n if (!config.custom.baseUrl) {\n console.error('Error: CUSTOM_BASE_URL is not set (or run /login → Custom)')\n process.exit(1)\n }\n if (!config.custom.apiKey) {\n console.error('Error: CUSTOM_API_KEY is not set (or run /login → Custom)')\n process.exit(1)\n }\n if (!config.custom.model) {\n console.error('Error: CUSTOM_MODEL is not set (or run /login → Custom)')\n process.exit(1)\n }\n }\n}\n\nexport type ProviderUpdateOptions = {\n /** Required for provider=custom; ignored otherwise. */\n baseUrl?: string\n /** Required for provider=custom; defaults to openai. */\n apiStyle?: CustomApiStyle\n}\n\nexport function updateProviderConfig(\n provider: Provider,\n model: string,\n apiKey: string,\n options: ProviderUpdateOptions = {},\n): boolean {\n const previousProvider = config.provider\n const previousModel = (config as unknown as Record)[provider]?.model\n const modelSelectionChanged = previousProvider !== provider || previousModel !== model\n\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n case 'codex':\n // Credentials live in auth.json; the apiKey arg is meaningless for codex, so we only record the chosen model.\n config.codex.model = model\n break\n case 'custom': {\n config.custom.apiKey = apiKey\n config.custom.model = model\n if (options.baseUrl !== undefined) config.custom.baseUrl = normalizeBaseUrl(options.baseUrl)\n if (options.apiStyle !== undefined) config.custom.apiStyle = options.apiStyle\n break\n }\n }\n if (modelSelectionChanged) {\n unsetSessionEffort()\n delete process.env.ASTRAEA_REASONING_EFFORT\n }\n return modelSelectionChanged\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n '# ─── Codex (ChatGPT subscription, OAuth) ──────────────────',\n '# Credentials are written to ~/.astraea/auth.json via /login; no secret here.',\n `# PROVIDER=codex`,\n `CODEX_MODEL=${config.codex.model}`,\n '',\n '# ─── Custom gateway (OpenAI- or Anthropic-compatible) ───',\n `CUSTOM_API_KEY=${config.custom.apiKey}`,\n `CUSTOM_BASE_URL=${config.custom.baseUrl}`,\n `CUSTOM_MODEL=${config.custom.model}`,\n `CUSTOM_API_STYLE=${config.custom.apiStyle}`,\n `# CUSTOM_MAX_TOKENS=${config.custom.maxTokens}`,\n `# CUSTOM_CONTEXT_WINDOW=${config.custom.contextWindow}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n\nfunction loginEnvUpdates(): Record {\n const updates: Record = { PROVIDER: config.provider }\n switch (config.provider) {\n case 'anthropic':\n updates.ANTHROPIC_API_KEY = config.anthropic.apiKey\n updates.ANTHROPIC_MODEL = config.anthropic.model\n break\n case 'deepseek':\n updates.DEEPSEEK_API_KEY = config.deepseek.apiKey\n updates.DEEPSEEK_MODEL = config.deepseek.model\n break\n case 'kimi':\n updates.KIMI_API_KEY = config.kimi.apiKey\n updates.KIMI_MODEL = config.kimi.model\n updates.KIMI_BASE_URL = config.kimi.baseUrl\n break\n case 'openai':\n updates.OPENAI_API_KEY = config.openai.apiKey\n updates.OPENAI_MODEL = config.openai.model\n updates.OPENAI_BASE_URL = config.openai.baseUrl\n break\n case 'ollama':\n updates.OLLAMA_MODEL = config.ollama.model\n updates.OLLAMA_BASE_URL = config.ollama.baseUrl\n break\n case 'codex':\n // No secret written to .env — the token lives in ~/.astraea/auth.json; we only record provider + model.\n updates.CODEX_MODEL = config.codex.model\n break\n case 'custom':\n updates.CUSTOM_API_KEY = config.custom.apiKey\n updates.CUSTOM_BASE_URL = config.custom.baseUrl\n updates.CUSTOM_MODEL = config.custom.model\n updates.CUSTOM_API_STYLE = config.custom.apiStyle\n break\n }\n return updates\n}\n\nexport async function saveLoginConfigToEnvFiles(\n globalDestination: string = globalEnvPath,\n projectDestination: string = envPath,\n): Promise {\n await saveConfigToEnv(globalDestination)\n if (existsSync(projectDestination)) {\n await mergeEnvFile(projectDestination, loginEnvUpdates())\n }\n}\n"}, "5f6a3d9e7716ab1dda680410855d21b31dd948c3": {"t": 1785754238.5360768, "n": 17227, "h": "9245bdd83c2eea5f", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\nimport { unsetSessionEffort } from './state/reasoningEffort'\nimport { loadCodexCredentials } from './auth/codexAuth'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai' | 'codex'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'codex') return 'codex'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n\n // Codex (ChatGPT subscription) — uses OAuth + the Responses API, no apiKey (credentials live in ~/.astraea/auth.json).\n codex: {\n model: process.env.CODEX_MODEL ?? 'gpt-5.4',\n baseUrl: 'https://chatgpt.com/backend-api',\n maxTokens: maxTokensFrom('CODEX_MAX_TOKENS', 32000),\n contextWindow: contextWindowFrom('CODEX_CONTEXT_WINDOW', 256_000),\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n case 'codex': return config.codex.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n case 'codex': return config.codex.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n case 'codex': return loadCodexCredentials() !== null\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'codex' && loadCodexCredentials() === null) {\n console.error('Error: not logged in to Codex — run /login and choose OpenAI Codex')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): boolean {\n const previousProvider = config.provider\n const previousModel = (config as unknown as Record)[provider]?.model\n const modelSelectionChanged = previousProvider !== provider || previousModel !== model\n\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n case 'codex':\n // Credentials live in auth.json; the apiKey arg is meaningless for codex, so we only record the chosen model.\n config.codex.model = model\n break\n }\n if (modelSelectionChanged) {\n unsetSessionEffort()\n delete process.env.ASTRAEA_REASONING_EFFORT\n }\n return modelSelectionChanged\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n '# ─── Codex (ChatGPT subscription, OAuth) ──────────────────',\n '# Credentials are written to ~/.astraea/auth.json via /login; no secret here.',\n `# PROVIDER=codex`,\n `CODEX_MODEL=${config.codex.model}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n\nfunction loginEnvUpdates(): Record {\n const updates: Record = { PROVIDER: config.provider }\n switch (config.provider) {\n case 'anthropic':\n updates.ANTHROPIC_API_KEY = config.anthropic.apiKey\n updates.ANTHROPIC_MODEL = config.anthropic.model\n break\n case 'deepseek':\n updates.DEEPSEEK_API_KEY = config.deepseek.apiKey\n updates.DEEPSEEK_MODEL = config.deepseek.model\n break\n case 'kimi':\n updates.KIMI_API_KEY = config.kimi.apiKey\n updates.KIMI_MODEL = config.kimi.model\n updates.KIMI_BASE_URL = config.kimi.baseUrl\n break\n case 'openai':\n updates.OPENAI_API_KEY = config.openai.apiKey\n updates.OPENAI_MODEL = config.openai.model\n updates.OPENAI_BASE_URL = config.openai.baseUrl\n break\n case 'ollama':\n updates.OLLAMA_MODEL = config.ollama.model\n updates.OLLAMA_BASE_URL = config.ollama.baseUrl\n break\n case 'codex':\n // No secret written to .env — the token lives in ~/.astraea/auth.json; we only record provider + model.\n updates.CODEX_MODEL = config.codex.model\n break\n }\n return updates\n}\n\nexport async function saveLoginConfigToEnvFiles(\n globalDestination: string = globalEnvPath,\n projectDestination: string = envPath,\n): Promise {\n await saveConfigToEnv(globalDestination)\n if (existsSync(projectDestination)) {\n await mergeEnvFile(projectDestination, loginEnvUpdates())\n }\n}\n"}, "571b6b080f806dd85976d5225583a87ef059c90e": {"t": 1785754241.760378, "n": 17055, "h": "caa22d2c0d1fffc7", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\nimport { unsetSessionEffort } from './state/reasoningEffort'\nimport { loadCodexCredentials } from './auth/codexAuth'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai' | 'codex'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'codex') return 'codex'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n\n // Codex(ChatGPT 订阅)—— 走 OAuth + Responses API,无 apiKey(凭据在 ~/.astraea/auth.json)。\n codex: {\n model: process.env.CODEX_MODEL ?? 'gpt-5.4',\n baseUrl: 'https://chatgpt.com/backend-api',\n maxTokens: maxTokensFrom('CODEX_MAX_TOKENS', 32000),\n contextWindow: contextWindowFrom('CODEX_CONTEXT_WINDOW', 256_000),\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n case 'codex': return config.codex.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n case 'codex': return config.codex.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n case 'codex': return loadCodexCredentials() !== null\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'codex' && loadCodexCredentials() === null) {\n console.error('Error: not logged in to Codex — run /login and choose OpenAI Codex')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): boolean {\n const previousProvider = config.provider\n const previousModel = (config as unknown as Record)[provider]?.model\n const modelSelectionChanged = previousProvider !== provider || previousModel !== model\n\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n case 'codex':\n // 凭据在 auth.json,apiKey 参数对 codex 无意义,仅记录所选模型。\n config.codex.model = model\n break\n }\n if (modelSelectionChanged) {\n unsetSessionEffort()\n delete process.env.ASTRAEA_REASONING_EFFORT\n }\n return modelSelectionChanged\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n '# ─── Codex(ChatGPT 订阅,OAuth)────────────────────────',\n '# 凭据经 /login 写入 ~/.astraea/auth.json,此处无 secret。',\n `# PROVIDER=codex`,\n `CODEX_MODEL=${config.codex.model}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n\nfunction loginEnvUpdates(): Record {\n const updates: Record = { PROVIDER: config.provider }\n switch (config.provider) {\n case 'anthropic':\n updates.ANTHROPIC_API_KEY = config.anthropic.apiKey\n updates.ANTHROPIC_MODEL = config.anthropic.model\n break\n case 'deepseek':\n updates.DEEPSEEK_API_KEY = config.deepseek.apiKey\n updates.DEEPSEEK_MODEL = config.deepseek.model\n break\n case 'kimi':\n updates.KIMI_API_KEY = config.kimi.apiKey\n updates.KIMI_MODEL = config.kimi.model\n updates.KIMI_BASE_URL = config.kimi.baseUrl\n break\n case 'openai':\n updates.OPENAI_API_KEY = config.openai.apiKey\n updates.OPENAI_MODEL = config.openai.model\n updates.OPENAI_BASE_URL = config.openai.baseUrl\n break\n case 'ollama':\n updates.OLLAMA_MODEL = config.ollama.model\n updates.OLLAMA_BASE_URL = config.ollama.baseUrl\n break\n case 'codex':\n // 无 secret 写入 .env —— token 在 ~/.astraea/auth.json,只记 provider + 模型。\n updates.CODEX_MODEL = config.codex.model\n break\n }\n return updates\n}\n\nexport async function saveLoginConfigToEnvFiles(\n globalDestination: string = globalEnvPath,\n projectDestination: string = envPath,\n): Promise {\n await saveConfigToEnv(globalDestination)\n if (existsSync(projectDestination)) {\n await mergeEnvFile(projectDestination, loginEnvUpdates())\n }\n}\n"}, "5aac4fb129a64892f3525829628f53fc50ad0a95": {"t": 1785754243.4679995, "n": 15814, "h": "8ab164c7a27f791f", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\nimport { unsetSessionEffort } from './state/reasoningEffort'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): boolean {\n const previousProvider = config.provider\n const previousModel = (config as unknown as Record)[provider]?.model\n const modelSelectionChanged = previousProvider !== provider || previousModel !== model\n\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n if (modelSelectionChanged) {\n unsetSessionEffort()\n delete process.env.ASTRAEA_REASONING_EFFORT\n }\n return modelSelectionChanged\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n\nfunction loginEnvUpdates(): Record {\n const updates: Record = { PROVIDER: config.provider }\n switch (config.provider) {\n case 'anthropic':\n updates.ANTHROPIC_API_KEY = config.anthropic.apiKey\n updates.ANTHROPIC_MODEL = config.anthropic.model\n break\n case 'deepseek':\n updates.DEEPSEEK_API_KEY = config.deepseek.apiKey\n updates.DEEPSEEK_MODEL = config.deepseek.model\n break\n case 'kimi':\n updates.KIMI_API_KEY = config.kimi.apiKey\n updates.KIMI_MODEL = config.kimi.model\n updates.KIMI_BASE_URL = config.kimi.baseUrl\n break\n case 'openai':\n updates.OPENAI_API_KEY = config.openai.apiKey\n updates.OPENAI_MODEL = config.openai.model\n updates.OPENAI_BASE_URL = config.openai.baseUrl\n break\n case 'ollama':\n updates.OLLAMA_MODEL = config.ollama.model\n updates.OLLAMA_BASE_URL = config.ollama.baseUrl\n break\n }\n return updates\n}\n\nexport async function saveLoginConfigToEnvFiles(\n globalDestination: string = globalEnvPath,\n projectDestination: string = envPath,\n): Promise {\n await saveConfigToEnv(globalDestination)\n if (existsSync(projectDestination)) {\n await mergeEnvFile(projectDestination, loginEnvUpdates())\n }\n}\n"}, "1258abc8cbdf03ad0720dfcf119d9bbc0694ff93": {"t": 1785754245.0895932, "n": 15379, "h": "72eab3d5e70b64c5", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n\nfunction loginEnvUpdates(): Record {\n const updates: Record = { PROVIDER: config.provider }\n switch (config.provider) {\n case 'anthropic':\n updates.ANTHROPIC_API_KEY = config.anthropic.apiKey\n updates.ANTHROPIC_MODEL = config.anthropic.model\n break\n case 'deepseek':\n updates.DEEPSEEK_API_KEY = config.deepseek.apiKey\n updates.DEEPSEEK_MODEL = config.deepseek.model\n break\n case 'kimi':\n updates.KIMI_API_KEY = config.kimi.apiKey\n updates.KIMI_MODEL = config.kimi.model\n updates.KIMI_BASE_URL = config.kimi.baseUrl\n break\n case 'openai':\n updates.OPENAI_API_KEY = config.openai.apiKey\n updates.OPENAI_MODEL = config.openai.model\n updates.OPENAI_BASE_URL = config.openai.baseUrl\n break\n case 'ollama':\n updates.OLLAMA_MODEL = config.ollama.model\n updates.OLLAMA_BASE_URL = config.ollama.baseUrl\n break\n }\n return updates\n}\n\nexport async function saveLoginConfigToEnvFiles(\n globalDestination: string = globalEnvPath,\n projectDestination: string = envPath,\n): Promise {\n await saveConfigToEnv(globalDestination)\n if (existsSync(projectDestination)) {\n await mergeEnvFile(projectDestination, loginEnvUpdates())\n }\n}\n"}, "3dee37170a5748cd14b08a29e650a66b95e516f4": {"t": 1785754246.3753948, "n": 14103, "h": "bf9c0322e1488255", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { chmodSync, mkdirSync, readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n mkdirSync(dirname(path), { recursive: true })\n await Bun.write(path, out.join('\\n'))\n chmodSync(path, 0o600)\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(destination: string = globalEnvPath): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n mkdirSync(dirname(destination), { recursive: true })\n await Bun.write(destination, content)\n chmodSync(destination, 0o600)\n}\n"}, "1dd6461c502ff5ddbbea7244d1c2af2cf3f75c9d": {"t": 1785754247.931466, "n": 13882, "h": "1c8b5957073ea2ce", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n // V4 默认 flash(便宜快);/reason high/max 自动升 deepseek-v4-pro。旧别名 deepseek-chat/reasoner\n // 2026-07-24 下线,仍可经 DEEPSEEK_MODEL 指定(走向后兼容路径)。\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',\n baseUrl: 'https://api.deepseek.com',\n // V4 输出上限 384K(CoT 走独立 reasoning_content,不占此预算);默认 8192 够 agent 单轮,env 可调高。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 1_000_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n await Bun.write(path, out.join('\\n'))\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "6d2680c73c203565a7335ccd4c6c80f0abc0dfc4": {"t": 1785754249.3514981, "n": 13688, "h": "095beeabf402eb6d", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 流式空闲看门狗超时(ms)— 两个 chunk 之间超过此时长没收到新事件,判定连接被悄悄掐断,\n// 主动 abort 流式请求并非流式重试一次。SDK 的请求超时只覆盖初始 fetch(),不覆盖流式 body,\n// 半开连接会无限挂起,headless 下无人按 ESC 即永久卡死。默认 90s,ASTRAEA_STREAM_IDLE_TIMEOUT_MS 覆盖。\nfunction streamIdleTimeoutFrom(): number {\n const raw = Number(process.env.ASTRAEA_STREAM_IDLE_TIMEOUT_MS)\n return Number.isFinite(raw) && raw > 0 ? raw : 90_000\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n // 流式空闲看门狗超时(ms)。见 streamIdleTimeoutFrom 注释。\n streamIdleTimeoutMs: streamIdleTimeoutFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n await Bun.write(path, out.join('\\n'))\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "8a02708ba1d5aeb2434c43624856306596b70599": {"t": 1785754250.9576457, "n": 13234, "h": "7d2533ccbe9d4806", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'kimi' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n if (raw === 'kimi' || raw === 'moonshot') return 'kimi'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Kimi(Moonshot AI)— OpenAI-compatible API\n // 国内默认走 api.moonshot.cn;海外可用 KIMI_BASE_URL=https://api.moonshot.ai/v1 覆盖。\n kimi: {\n apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? '',\n model: process.env.KIMI_MODEL ?? 'kimi-k2-0905-preview',\n baseUrl: process.env.KIMI_BASE_URL ?? 'https://api.moonshot.cn/v1',\n // kimi-k2 输出上限较高,保守默认 8192,按需用 KIMI_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('KIMI_MAX_TOKENS', 8192),\n // kimi-k2 上下文窗口 256K;moonshot-v1-128k 等较小,按实际模型用 KIMI_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('KIMI_CONTEXT_WINDOW', 256_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'kimi': return config.kimi.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'kimi': return config.kimi.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'kimi': return !!config.kimi.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'kimi' && !config.kimi.apiKey) {\n console.error('Error: KIMI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'kimi':\n config.kimi.apiKey = apiKey\n config.kimi.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n await Bun.write(path, out.join('\\n'))\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Kimi(Moonshot AI)─────────────────────────────────',\n `KIMI_API_KEY=${config.kimi.apiKey}`,\n `KIMI_MODEL=${config.kimi.model}`,\n `KIMI_BASE_URL=${config.kimi.baseUrl}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "53696812db4500d215f0efb01141f44082531033": {"t": 1785754254.4009044, "n": 11964, "h": "df005fd154344fe7", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\n// ─── 搜索 provider 配置(/internet 向导用)──────────────────────────────────\n// 与 LLM provider 解耦:搜索 Key 走独立的 ASTRAEA_SEARCH_ADAPTER + _API_KEY,\n// 不进 saveConfigToEnv(那个只管 LLM provider,且会整文件覆写项目 .env)。\n// label 用品牌名(各语言通用);hint 为 i18n key(运行时按当前语言 t() 解析),不存翻译文本。\nexport interface SearchProviderMeta {\n id: string\n label: string\n hintKey: string\n envVar: string\n signupUrl: string\n domestic: boolean // 国内是否可直连(无需代理)\n}\n\nexport const SEARCH_PROVIDERS: SearchProviderMeta[] = [\n { id: 'bocha', label: 'Bocha', hintKey: 'provHintBocha', envVar: 'BOCHA_API_KEY', signupUrl: 'https://open.bochaai.com', domestic: true },\n { id: 'zhipu', label: 'Zhipu BigModel', hintKey: 'provHintZhipu', envVar: 'ZHIPU_API_KEY', signupUrl: 'https://open.bigmodel.cn', domestic: true },\n { id: 'tavily', label: 'Tavily', hintKey: 'provHintTavily', envVar: 'TAVILY_API_KEY', signupUrl: 'https://app.tavily.com/sign-up', domestic: false },\n { id: 'brave', label: 'Brave Search', hintKey: 'provHintBrave', envVar: 'BRAVE_SEARCH_API_KEY', signupUrl: 'https://brave.com/search/api/', domestic: false },\n { id: 'exa', label: 'Exa', hintKey: 'provHintExa', envVar: 'EXA_API_KEY', signupUrl: 'https://dashboard.exa.ai', domestic: false },\n]\n\nexport function searchProviderMeta(id: string): SearchProviderMeta | undefined {\n return SEARCH_PROVIDERS.find(p => p.id === id)\n}\n\n// 当前激活的搜索 provider(已配 Key 的那个);用于 /internet 向导展示状态。\nexport function activeSearchProvider(): SearchProviderMeta | undefined {\n const setting = process.env.ASTRAEA_SEARCH_ADAPTER?.trim()\n if (setting && setting !== 'auto') return searchProviderMeta(setting)\n // auto:返回第一个配了 Key 的(与 WebSearchTool 的探测链同序)\n return SEARCH_PROVIDERS.find(p => process.env[p.envVar])\n}\n\n// 把 updates 合并进 env 文件:已存在的 key 原地改值,新 key 追加到末尾,注释/空行保留。\nasync function mergeEnvFile(path: string, updates: Record): Promise {\n let lines: string[] = []\n try {\n lines = (await Bun.file(path).text()).split('\\n')\n } catch {\n // 文件不存在 → 从空开始(Bun.write 会自动建 ~/.astraea 目录)\n }\n const remaining = { ...updates }\n const out = lines.map(line => {\n const t = line.trim()\n if (!t || t.startsWith('#')) return line\n const eq = t.indexOf('=')\n if (eq === -1) return line\n const key = t.slice(0, eq).trim()\n if (key in remaining) {\n const v = remaining[key]!\n delete remaining[key]\n return `${key}=${v}`\n }\n return line\n })\n for (const [k, v] of Object.entries(remaining)) out.push(`${k}=${v}`)\n await Bun.write(path, out.join('\\n'))\n}\n\n// 保存搜索 provider 的 Key 到 ~/.astraea/.env(全局 secrets,一次配置所有项目生效),\n// 并设为当前激活适配器;同时即时写入 process.env,本次会话无需重启即可生效。\nexport async function saveSearchProviderKey(providerId: string, apiKey: string): Promise {\n const meta = searchProviderMeta(providerId)\n if (!meta) throw new Error(`未知搜索 provider:${providerId}`)\n process.env[meta.envVar] = apiKey\n process.env.ASTRAEA_SEARCH_ADAPTER = providerId\n await mergeEnvFile(globalEnvPath, {\n [meta.envVar]: apiKey,\n ASTRAEA_SEARCH_ADAPTER: providerId,\n })\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "bba319d86afc9d2216f33ba012d52df5ebf69f2f": {"t": 1785754255.9801757, "n": 8832, "h": "0567fe0bfa4dc45e", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\n// 当前激活 provider 是否已配置可用的 API Key。\n// ollama 走本地、无需 key,视为永远「已配置」。\nexport function hasValidConfig(): boolean {\n switch (config.provider) {\n case 'anthropic': return !!config.anthropic.apiKey\n case 'deepseek': return !!config.deepseek.apiKey\n case 'openai': return !!config.openai.apiKey\n case 'ollama': return true\n default: return false\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "585a4f2c1a64409ecbe0af717a8da071d311d7da": {"t": 1785754257.3107233, "n": 8455, "h": "aadd7e5fc46f3ac8", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. ~/.astraea/settings.json 的 env 块 — 行为开关(如 PHOENIX_ENABLED)\n// 4. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\nimport { getSettings } from './settings'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 把 ~/.astraea/settings.json 的 env 块灌进 process.env(沿用\"不覆盖已存在值\"规则)。\n// 在 .env 之前应用 → 仅 shell 已设的值能压过它;之后 .env 只填它没设的 key。\n// 即优先级:shell > settings.json > 项目 .env > 全局 .env。\nfunction applySettingsEnv(): void {\n const env = getSettings().env\n if (!env) return\n for (const [key, val] of Object.entries(env)) {\n if (typeof val === 'string' && !(key in process.env)) process.env[key] = val\n }\n}\n\n// 加载顺序:settings.json 先于 .env;项目 .env 先于全局 .env。\n// loadEnvFile / applySettingsEnv 都用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量 > settings.json > 项目 .env > ~/.astraea/.env\napplySettingsEnv() // 先吃 settings.json 的 env,让它占位(shell 已占的除外)\nloadEnvFile(envPath) // 再加载项目级,只填它没设的 key\nloadEnvFile(globalEnvPath) // 最后全局,只填仍缺的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "712ddd368eac4dc9526f731878b34f0762f9c3a9": {"t": 1785754259.4045749, "n": 7830, "h": "6522d6603f85e8f2", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 加载顺序:项目 .env 先于全局 .env。\n// loadEnvFile 用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量(已在 process.env)> 项目 .env > ~/.astraea/.env\nloadEnvFile(envPath) // 先加载项目级,让项目值先占位\nloadEnvFile(globalEnvPath) // 再加载全局,只填入项目和 shell 都没有的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// Eclipse(上下文折叠)总开关:默认【关】;ASTRAEA_ECLIPSE 设为 1/true/on/yes → 开启。\n// 开启时:后台 ctx-agent 周期折叠中段、0.85 提交、0.95 阻塞现折,且压制主动 autocompact。\nfunction eclipseEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_ECLIPSE?.trim().toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n // Eclipse 折叠总开关(默认关)。开启时压制主动 autocompact,由折叠接管 0.85~0.95 带。\n eclipse: eclipseEnabledFrom(),\n\n // ctx-agent 可选模型覆盖:默认用 querySmallModel 的 per-provider 小模型;\n // 设了就用它(如想用更强模型提升折叠摘要保真度,不影响 WebFetch 等其它小模型调用方)。\n ctxAgentModel: process.env.CTX_AGENT_MODEL?.trim() || undefined,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "afb99fcd84784c0ab58d5f54fe8ad854a826de5c": {"t": 1785754261.7535276, "n": 7240, "h": "9702843a01543b8a", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 加载顺序:项目 .env 先于全局 .env。\n// loadEnvFile 用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量(已在 process.env)> 项目 .env > ~/.astraea/.env\nloadEnvFile(envPath) // 先加载项目级,让项目值先占位\nloadEnvFile(globalEnvPath) // 再加载全局,只填入项目和 shell 都没有的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 上下文窗口(total context window)— 与 maxTokens 对称的 per-provider 配置。\n// 各 provider/model 真实窗口差异极大(本地 8K ~ Anthropic 1M),不能写死一个常量,\n// 必须按 provider 给默认值并支持 _CONTEXT_WINDOW 覆盖。详见上下文设计文档 §1。\nfunction contextWindowFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// autocompact 总开关:默认开;ASTRAEA_AUTOCOMPACT 设为 0/false/off/no → 关闭。\n// 关闭时不自动压缩,改走 0.98 硬阻塞,强制用户手动 /compact(设计文档 §4/§9)。\nfunction autocompactEnabledFrom(): boolean {\n const raw = process.env.ASTRAEA_AUTOCOMPACT?.trim().toLowerCase()\n return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n // autocompact 总开关(设计文档 §9)。关闭时走 0.98 硬阻塞。\n autocompact: autocompactEnabledFrom(),\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n // 默认 200K(人人可用的安全窗口);需要 1M 的人用 env 开。Sonnet 4.6/Opus 真实窗口是 1M。\n contextWindow: contextWindowFrom('ANTHROPIC_CONTEXT_WINDOW', 200_000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n contextWindow: contextWindowFrom('DEEPSEEK_CONTEXT_WINDOW', 128_000),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n // 本地窗口由 num_ctx 决定,保守默认 32K,须按实际模型用 OLLAMA_CONTEXT_WINDOW 调。\n contextWindow: contextWindowFrom('OLLAMA_CONTEXT_WINDOW', 32_000),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n contextWindow: contextWindowFrom('OPENAI_CONTEXT_WINDOW', 128_000),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\n// 当前激活 provider 的窗口与输出上限 —— 阈值每次现算时调用(设计文档 §6:阈值现算随 provider)。\nexport function activeContextWindow(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.contextWindow\n case 'ollama': return config.ollama.contextWindow\n case 'openai': return config.openai.contextWindow\n default: return config.anthropic.contextWindow\n }\n}\n\nexport function activeMaxTokens(): number {\n switch (config.provider) {\n case 'deepseek': return config.deepseek.maxTokens\n case 'ollama': return config.ollama.maxTokens\n case 'openai': return config.openai.maxTokens\n default: return config.anthropic.maxTokens\n }\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "90d3e9a0e03dc48d5b684df8e70b71b631c04312": {"t": 1785754263.7382128, "n": 5378, "h": "d923a6674bbe5aff", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 加载顺序:项目 .env 先于全局 .env。\n// loadEnvFile 用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量(已在 process.env)> 项目 .env > ~/.astraea/.env\nloadEnvFile(envPath) // 先加载项目级,让项目值先占位\nloadEnvFile(globalEnvPath) // 再加载全局,只填入项目和 shell 都没有的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\n// 输出 token 上限 — 旧值统一 8192,对自包含 HTML/SVG 这类大产物会自我缩水或中途截断。\n// 各 provider 默认提到对应模型的真实上限;可用 _MAX_TOKENS 环境变量覆盖。\nfunction maxTokensFrom(envKey: string, fallback: number): number {\n const raw = Number(process.env[envKey])\n return Number.isFinite(raw) && raw > 0 ? raw : fallback\n}\n\n// 提前解析,供 maxTokens 的模型相关默认值复用(gpt-5.x vs gpt-4o 输出预算不同)\nconst openaiModel = process.env.OPENAI_MODEL ?? 'gpt-4o'\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n // Sonnet 4.x 支持 64k 输出;32k 与 Claude Code 对齐,留足大文件余量。\n maxTokens: maxTokensFrom('ANTHROPIC_MAX_TOKENS', 32000),\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n // deepseek-chat 输出硬上限即 8192。\n maxTokens: maxTokensFrom('DEEPSEEK_MAX_TOKENS', 8192),\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n // 本地模型受显存/上下文限制,保守默认,按需用 OLLAMA_MAX_TOKENS 调高。\n maxTokens: maxTokensFrom('OLLAMA_MAX_TOKENS', 8192),\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: openaiModel,\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n // gpt-5.x 支持 128K 输出;给它 32k 单次产物余量(足够大型自包含 HTML),gpt-4o 维持 16384 硬上限。\n maxTokens: maxTokensFrom('OPENAI_MAX_TOKENS', /^gpt-5/i.test(openaiModel) ? 32000 : 16384),\n // gpt-5.x 推理强度:none|low|medium|high|xhigh,默认 medium。仅对 reasoning 模型生效。\n reasoningEffort: process.env.OPENAI_REASONING_EFFORT?.trim() || undefined,\n },\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}, "29c3c0bb5f6a343cd58be36cb5aa3ddbce99013a": {"t": 1785754264.936335, "n": 4436, "h": "300ffa2c622114e5", "c": "// 全局配置 — 从环境变量读取,不硬编码敏感值\n//\n// 加载优先级(低 → 高,高优先级覆盖低优先级):\n// 1. ~/.astraea/.env — 用户个人全局 secrets(API key 放这里,一次配置所有项目生效)\n// 2. /.env — 项目级覆盖(开发调试用,勿提交 key)\n// 3. shell 环境变量 — 最高优先级\nimport { readFileSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { fileURLToPath } from 'url'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nexport const envPath = join(__dirname, '..', '.env')\nexport const globalEnvPath = join(homedir(), '.astraea', '.env')\n\nfunction loadEnvFile(path: string): void {\n try {\n const lines = readFileSync(path, 'utf-8').split('\\n')\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed || trimmed.startsWith('#')) continue\n const eq = trimmed.indexOf('=')\n if (eq === -1) continue\n const key = trimmed.slice(0, eq).trim()\n const val = trimmed.slice(eq + 1).trim()\n // 不覆盖已存在的值(shell 环境变量 > 项目 .env > 全局 .env)\n if (key && !(key in process.env)) process.env[key] = val\n }\n } catch {\n // 文件不存在则跳过\n }\n}\n\n// 加载顺序:项目 .env 先于全局 .env。\n// loadEnvFile 用 !(key in process.env) 跳过已存在的值,所以:\n// shell 环境变量(已在 process.env)> 项目 .env > ~/.astraea/.env\nloadEnvFile(envPath) // 先加载项目级,让项目值先占位\nloadEnvFile(globalEnvPath) // 再加载全局,只填入项目和 shell 都没有的 key\n\nexport type Provider = 'anthropic' | 'deepseek' | 'ollama' | 'openai'\n\nfunction detectProvider(): Provider {\n const raw = process.env.PROVIDER?.toLowerCase()\n if (raw === 'ollama') return 'ollama'\n if (raw === 'openai') return 'openai'\n if (raw === 'deepseek') return 'deepseek'\n return 'anthropic'\n}\n\nexport const config = {\n provider: detectProvider() as Provider,\n\n anthropic: {\n apiKey: process.env.ANTHROPIC_API_KEY ?? '',\n model: process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6',\n maxTokens: 8192,\n },\n\n // DeepSeek — OpenAI-compatible API\n deepseek: {\n apiKey: process.env.DEEPSEEK_API_KEY ?? '',\n model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',\n baseUrl: 'https://api.deepseek.com',\n maxTokens: 8192,\n },\n\n // Ollama(本地)\n ollama: {\n baseUrl: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',\n model: process.env.OLLAMA_MODEL ?? 'qwen2.5:7b',\n maxTokens: 8192,\n },\n\n // OpenAI(云端)\n openai: {\n apiKey: process.env.OPENAI_API_KEY ?? '',\n model: process.env.OPENAI_MODEL ?? 'gpt-4o',\n baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',\n maxTokens: 8192,\n },\n}\n\nexport function assertConfig(): void {\n if (config.provider === 'anthropic' && !config.anthropic.apiKey) {\n console.error('Error: ANTHROPIC_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'deepseek' && !config.deepseek.apiKey) {\n console.error('Error: DEEPSEEK_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n if (config.provider === 'openai' && !config.openai.apiKey) {\n console.error('Error: OPENAI_API_KEY is not set (or run /login)')\n process.exit(1)\n }\n}\n\nexport function updateProviderConfig(provider: Provider, model: string, apiKey: string): void {\n config.provider = provider\n switch (provider) {\n case 'anthropic':\n config.anthropic.apiKey = apiKey\n config.anthropic.model = model\n break\n case 'deepseek':\n config.deepseek.apiKey = apiKey\n config.deepseek.model = model\n break\n case 'openai':\n config.openai.apiKey = apiKey\n config.openai.model = model\n break\n }\n}\n\nexport async function saveConfigToEnv(): Promise {\n const content = [\n '# ─── Provider 选择 ───────────────────────────────────────',\n `PROVIDER=${config.provider}`,\n '',\n '# ─── Anthropic ──────────────────────────────────────────',\n `ANTHROPIC_API_KEY=${config.anthropic.apiKey}`,\n `ANTHROPIC_MODEL=${config.anthropic.model}`,\n '',\n '# ─── DeepSeek ───────────────────────────────────────────',\n `DEEPSEEK_API_KEY=${config.deepseek.apiKey}`,\n `DEEPSEEK_MODEL=${config.deepseek.model}`,\n '',\n '# ─── Ollama(本地)────────────────────────────────────────',\n `# OLLAMA_BASE_URL=${config.ollama.baseUrl}`,\n `# OLLAMA_MODEL=${config.ollama.model}`,\n '',\n '# ─── OpenAI ─────────────────────────────────────────────',\n `OPENAI_API_KEY=${config.openai.apiKey}`,\n `OPENAI_MODEL=${config.openai.model}`,\n `OPENAI_BASE_URL=${config.openai.baseUrl}`,\n '',\n ].join('\\n')\n await Bun.write(envPath, content)\n}\n"}}}}