Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/anxelswanz_astraea-agent.json
T

1 line
262 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<string, { model?: string }>)[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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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<string, string> {\n const updates: Record<string, string> = { 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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<string, { model?: string }>)[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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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<string, string> {\n const updates: Record<string, string> = { 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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<string, { model?: string }>)[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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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<string, string> {\n const updates: Record<string, string> = { 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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<string, { model?: string }>)[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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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<string, string> {\n const updates: Record<string, string> = { 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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<string, { model?: string }>)[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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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<string, string> {\n const updates: Record<string, string> = { 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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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<string, string> {\n const updates: Record<string, string> = { 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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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 + <PROVIDER>_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<string, string>): Promise<void> {\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<void> {\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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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 给默认值并支持 <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<void> {\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. <project>/.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 默认提到对应模型的真实上限;可用 <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<void> {\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. <project>/.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<void> {\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"}}}}