diff --git a/.gitignore b/.gitignore index d6fb05465..030c084e3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ packages/web/public/worker-*.js .augment/ .codex/ .gemini/ +.kimi/ .kiro/ .trae/ .dare/ @@ -66,6 +67,7 @@ npm-debug.log* pnpm-debug.log* # Lockfiles (workspace uses pnpm only) +package-lock.json packages/**/package-lock.json # Test coverage @@ -130,6 +132,9 @@ private/ # Playwright MCP cache .playwright-mcp/ +# Local OMX runtime state +.omx/ + # SQLite runtime databases (Hindsight evidence store) evidence.sqlite evidence.sqlite-shm diff --git a/cat-config.json b/cat-config.json index ed6383e28..3e4cab101 100644 --- a/cat-config.json +++ b/cat-config.json @@ -82,6 +82,13 @@ "lead": true, "available": true, "evaluation": "开源多模型编码猫,自带 Oh My OpenCode 多专家编排 + LSP (F105)" + }, + "kimi": { + "family": "moonshot", + "roles": ["research", "writer"], + "lead": true, + "available": true, + "evaluation": "中文长文理解与总结强,适合中文表达、资料整理与结构化输出" } }, "reviewPolicy": { @@ -638,6 +645,43 @@ } } ] + }, + { + "id": "moonshot", + "catId": "kimi", + "name": "梵花猫", + "displayName": "梵花猫", + "avatar": "/avatars/kimi.png", + "color": { + "primary": "#4B5563", + "secondary": "#E5E7EB" + }, + "mentionPatterns": ["@kimi", "@moonshot", "@月之暗面", "@梵花猫"], + "roleDescription": "中文长文本助手,擅长中文语境理解、资料整理与结构化表达", + "teamStrengths": "中文长文、总结归纳、资料整理", + "caution": null, + "defaultVariantId": "kimi-default", + "variants": [ + { + "id": "kimi-default", + "clientId": "kimi", + "defaultModel": "kimi-code/kimi-for-coding", + "mcpSupport": true, + "cli": { + "command": "kimi", + "outputFormat": "stream-json", + "defaultArgs": ["--print", "--output-format", "stream-json"] + }, + "personality": "稳健细致,擅长长文阅读和中文语境下的结构化表达", + "strengths": ["long-context", "chinese-writing", "summarization"], + "contextBudget": { + "maxPromptTokens": 240000, + "maxContextTokens": 216000, + "maxMessages": 200, + "maxContentLengthPerMsg": 10000 + } + } + ] } ] } diff --git a/cat-template.json b/cat-template.json index 00c56bdba..1a19cb7bf 100644 --- a/cat-template.json +++ b/cat-template.json @@ -82,6 +82,13 @@ "lead": true, "available": true, "evaluation": "开源多模型编码猫,自带 Oh My OpenCode 多专家编排 + LSP (F105)" + }, + "kimi": { + "family": "moonshot", + "roles": ["research", "writer"], + "lead": true, + "available": true, + "evaluation": "中文长文理解与总结强,适合中文表达、资料整理与结构化输出" } }, "reviewPolicy": { @@ -638,6 +645,43 @@ } } ] + }, + { + "id": "moonshot", + "catId": "kimi", + "name": "梵花猫", + "displayName": "梵花猫", + "avatar": "/avatars/kimi.png", + "color": { + "primary": "#4B5563", + "secondary": "#E5E7EB" + }, + "mentionPatterns": ["@kimi", "@moonshot", "@月之暗面", "@梵花猫"], + "roleDescription": "中文长文本助手,擅长中文语境理解、资料整理与结构化表达", + "teamStrengths": "中文长文、总结归纳、资料整理", + "caution": null, + "defaultVariantId": "kimi-default", + "variants": [ + { + "id": "kimi-default", + "clientId": "kimi", + "defaultModel": "kimi-code/kimi-for-coding", + "mcpSupport": true, + "cli": { + "command": "kimi", + "outputFormat": "stream-json", + "defaultArgs": ["--print", "--output-format", "stream-json"] + }, + "personality": "稳健细致,擅长长文阅读和中文语境下的结构化表达", + "strengths": ["long-context", "chinese-writing", "summarization"], + "contextBudget": { + "maxPromptTokens": 240000, + "maxContextTokens": 216000, + "maxMessages": 200, + "maxContentLengthPerMsg": 10000 + } + } + ] } ] } diff --git a/packages/api/package.json b/packages/api/package.json index 01af390b3..0d46f7a78 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,7 +9,7 @@ "build": "pnpm --dir ../shared build && tsc", "start": "node dist/index.js", "test": "pnpm run build && CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 node --test --test-timeout=60000 test/*.test.js test/**/*.test.js", - "test:public": "pnpm run build && CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 node --test --test-concurrency=1 $(ls test/*.test.js test/**/*.test.js 2>/dev/null | grep -v 'redis-' | grep -v 'concurrent-fault-drill' | grep -v 'task-progress-store' | grep -v 'session-strategy-phase3' | grep -v 'signal-article-store' | grep -v 'persistence-fault-drill' | grep -v 'cursor-store-atomicity' | grep -v 'workflow-sop-store' | grep -v 'dare-agent-service' | grep -v 'dare-l1-acceptance' | grep -v 'codex-agent-service' | grep -v 'claude-settings-hooks\\.test' | grep -v 'game-store\\.test' | grep -v 'test/memory/' | grep -v 'cross-cat-context\\.test' | grep -v 'thread-wiring\\.test' | grep -v 'integration/wiring\\.test' | grep -v 'antigravity-cdp-client\\.test' | grep -v 'shared-state-wiring\\.test' | grep -v 'signal-fetcher-launchd' | grep -v 'reflection-capsule-m3' | grep -v 'workspace-project-context\\.test' | grep -v 'projects-setup\\.test' | grep -v 'projects-mkdir\\.test' | grep -v 'governance-status\\.test' | grep -v 'pack-integration\\.test' | grep -v 'project-setup-flow\\.test' | grep -v 'process-liveness-probe\\.test' | tr '\\n' ' ')", + "test:public": "pnpm run build && CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 node --test --test-concurrency=1 $(ls test/*.test.js test/**/*.test.js 2>/dev/null | grep -v 'redis-' | grep -v 'concurrent-fault-drill' | grep -v 'task-progress-store' | grep -v 'session-strategy-phase3' | grep -v 'signal-article-store' | grep -v 'persistence-fault-drill' | grep -v 'cursor-store-atomicity' | grep -v 'workflow-sop-store' | grep -v 'dare-agent-service' | grep -v 'dare-l1-acceptance' | grep -v 'codex-agent-service' | grep -v 'kimi-agent-service' | grep -v 'claude-settings-hooks\\.test' | grep -v 'game-store\\.test' | grep -v 'test/memory/' | grep -v 'cross-cat-context\\.test' | grep -v 'thread-wiring\\.test' | grep -v 'integration/wiring\\.test' | grep -v 'antigravity-cdp-client\\.test' | grep -v 'shared-state-wiring\\.test' | grep -v 'signal-fetcher-launchd' | grep -v 'reflection-capsule-m3' | grep -v 'workspace-project-context\\.test' | grep -v 'projects-setup\\.test' | grep -v 'projects-mkdir\\.test' | grep -v 'governance-status\\.test' | grep -v 'pack-integration\\.test' | grep -v 'project-setup-flow\\.test' | grep -v 'process-liveness-probe\\.test' | tr '\\n' ' ')", "test:antigravity-smoke": "RUN_ANTIGRAVITY_SMOKE=true pnpm run build && RUN_ANTIGRAVITY_SMOKE=true node --test test/antigravity-smoke.test.js", "fetch-signals": "node dist/scripts/fetch-signals.js", "migrate-signals": "node dist/scripts/migrate-signals.js", diff --git a/packages/api/src/config/account-resolver.ts b/packages/api/src/config/account-resolver.ts index 515464349..ee49ee19b 100644 --- a/packages/api/src/config/account-resolver.ts +++ b/packages/api/src/config/account-resolver.ts @@ -10,7 +10,7 @@ import { readCredential } from './credentials.js'; // ── Types surviving from provider-profiles.types.ts (F136 Phase 4d) ── -export type BuiltinAccountClient = Extract; +export type BuiltinAccountClient = Extract; export type ProviderProfileKind = 'builtin' | 'api_key'; export interface RuntimeProviderProfile { @@ -37,6 +37,7 @@ export function resolveBuiltinClientForProvider(provider: ClientId): BuiltinAcco case 'anthropic': case 'openai': case 'google': + case 'kimi': case 'dare': case 'opencode': return provider; @@ -51,6 +52,7 @@ const LEGACY_BUILTIN_IDS: Record = { anthropic: 'claude', openai: 'codex', google: 'gemini', + kimi: 'kimi', dare: 'dare', opencode: 'opencode', }; @@ -82,6 +84,8 @@ const BUILTIN_ACCOUNT_MAP: Record/.mcp.json codexConfig: string; // e.g. /.codex/config.toml geminiConfig: string; // e.g. /.gemini/settings.json + kimiConfig: string; // e.g. /.kimi/mcp.json } /** @@ -360,13 +364,14 @@ export interface DiscoveryPaths { * Merges by name; if same name appears in multiple, first wins. */ export async function discoverExternalMcpServers(paths: DiscoveryPaths): Promise { - const [claude, codex, gemini] = await Promise.all([ + const [claude, codex, gemini, kimi] = await Promise.all([ readClaudeMcpConfig(paths.claudeConfig), readCodexMcpConfig(paths.codexConfig), readGeminiMcpConfig(paths.geminiConfig), + readKimiMcpConfig(paths.kimiConfig), ]); return deduplicateDiscoveredMcpServers( - [...claude, ...codex, ...gemini] + [...claude, ...codex, ...gemini, ...kimi] .filter((server) => hasUsableTransport(server)) .map((server) => ({ ...server, source: 'external' as const })), ); @@ -608,10 +613,11 @@ export interface CliConfigPaths { anthropic: string; // e.g. /.mcp.json openai: string; // e.g. /.codex/config.toml google: string; // e.g. /.gemini/settings.json + kimi: string; // e.g. /.kimi/mcp.json } /** Providers that support streamableHttp transport (URL-based MCP). */ -const STREAMABLE_HTTP_PROVIDERS = new Set(['anthropic']); +const STREAMABLE_HTTP_PROVIDERS = new Set(['anthropic', 'kimi']); /** * Resolve effective MCP servers for a specific cat. diff --git a/packages/api/src/config/capabilities/mcp-config-adapters.ts b/packages/api/src/config/capabilities/mcp-config-adapters.ts index d933b6368..b02e7c71e 100644 --- a/packages/api/src/config/capabilities/mcp-config-adapters.ts +++ b/packages/api/src/config/capabilities/mcp-config-adapters.ts @@ -6,6 +6,7 @@ * Claude: .mcp.json — { mcpServers: { name: { command, args, env } } } * Codex: .codex/config.toml — [mcp_servers.] command/args/env/enabled * Gemini: .gemini/settings.json — { mcpServers: { name: { command, args, env, cwd } } } + * Kimi: .kimi/mcp.json — { mcpServers: { name: { url|command, args, env, headers } } } */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; @@ -20,6 +21,13 @@ const GEMINI_CAT_CAFE_ENV_PLACEHOLDERS: Readonly> = { CAT_CAFE_USER_ID: '${CAT_CAFE_USER_ID}', CAT_CAFE_SIGNAL_USER: '${CAT_CAFE_SIGNAL_USER}', }; +const KIMI_CAT_CAFE_ENV_PLACEHOLDERS: Readonly> = { + CAT_CAFE_API_URL: '${CAT_CAFE_API_URL}', + CAT_CAFE_INVOCATION_ID: '${CAT_CAFE_INVOCATION_ID}', + CAT_CAFE_CALLBACK_TOKEN: '${CAT_CAFE_CALLBACK_TOKEN}', + CAT_CAFE_USER_ID: '${CAT_CAFE_USER_ID}', + CAT_CAFE_SIGNAL_USER: '${CAT_CAFE_SIGNAL_USER}', +}; function isCatCafeServer(name: string): boolean { return name === 'cat-cafe' || name.startsWith('cat-cafe-'); @@ -33,6 +41,14 @@ function ensureGeminiCatCafeEnv(name: string, env?: Record): Rec }; } +function ensureKimiCatCafeEnv(name: string, env?: Record): Record | undefined { + if (!isCatCafeServer(name)) return env; + return { + ...KIMI_CAT_CAFE_ENV_PLACEHOLDERS, + ...(env ?? {}), + }; +} + // ────────── Readers ────────── /** Read Claude .mcp.json → McpServerDescriptor[] */ @@ -87,6 +103,22 @@ export async function readGeminiMcpConfig(filePath: string): Promise { + const raw = await safeReadFile(filePath); + if (!raw) return []; + + const data = safeJsonParse(raw); + if (!data) return []; + + const servers = data.mcpServers; + if (!servers || typeof servers !== 'object') return []; + + return Object.entries(servers as Record>).map(([name, cfg]) => + toDescriptor(name, cfg, true), + ); +} + // ────────── Writers ────────── /** Write McpServerDescriptor[] → Claude .mcp.json (merge: preserves user's non-managed servers) */ @@ -273,9 +305,64 @@ export async function cleanStaleClaudeProjectOverrides( return cleaned; } +/** Write McpServerDescriptor[] → Kimi .kimi/mcp.json (merge: preserves user's non-managed servers) */ +export async function writeKimiMcpConfig(filePath: string, servers: McpServerDescriptor[]): Promise { + const raw = await safeReadFile(filePath); + let existing: Record = {}; + if (raw) { + const parsed = safeJsonParse(raw); + if (parsed) existing = parsed; + } + + const existingMcp: Record = + existing.mcpServers && typeof existing.mcpServers === 'object' + ? { ...(existing.mcpServers as Record) } + : {}; + + for (const s of servers) { + if (!s.enabled) { + delete existingMcp[s.name]; + continue; + } + if (s.transport === 'streamableHttp') { + if (!s.url?.trim()) { + delete existingMcp[s.name]; + continue; + } + const entry: Record = { url: s.url }; + if (s.headers && Object.keys(s.headers).length > 0) entry.headers = s.headers; + existingMcp[s.name] = entry; + continue; + } + if (!s.command || s.command.trim().length === 0) { + delete existingMcp[s.name]; + continue; + } + const entry: Record = { command: s.command, args: s.args }; + const env = ensureKimiCatCafeEnv(s.name, s.env); + if (env && Object.keys(env).length > 0) entry.env = env; + if (s.workingDir) entry.cwd = s.workingDir; + existingMcp[s.name] = entry; + } + + for (const [name, value] of Object.entries(existingMcp)) { + if (!isCatCafeServer(name)) continue; + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const cfg = value as Record; + const currentEnv = toStringRecord(cfg.env); + cfg.env = ensureKimiCatCafeEnv(name, currentEnv); + existingMcp[name] = cfg; + } + + existing.mcpServers = existingMcp; + await ensureDir(filePath); + await writeFile(filePath, `${JSON.stringify(existing, null, 2)}\n`, 'utf-8'); +} + // ────────── Helpers ────────── -async function safeReadFile(filePath: string): Promise { +async function safeReadFile(filePath?: string): Promise { + if (!filePath) return null; try { return await readFile(filePath, 'utf-8'); } catch { @@ -308,7 +395,8 @@ function toStringRecord(val: unknown): Record | undefined { } function toDescriptor(name: string, cfg: Record, enabled: boolean): McpServerDescriptor { - const isHttp = cfg.type === 'streamableHttp' || cfg.type === 'http'; + const isHttp = + cfg.type === 'streamableHttp' || cfg.type === 'http' || (typeof cfg.url === 'string' && cfg.url.length > 0); const desc: McpServerDescriptor = { name, command: typeof cfg.command === 'string' ? cfg.command : '', diff --git a/packages/api/src/config/cat-catalog-store.ts b/packages/api/src/config/cat-catalog-store.ts index bd1470e44..97cda0f7a 100644 --- a/packages/api/src/config/cat-catalog-store.ts +++ b/packages/api/src/config/cat-catalog-store.ts @@ -32,7 +32,7 @@ function writeFileAtomic(filePath: string, content: string): void { } /** F340 P5: ClientId values — used to detect old `provider` field holding a clientId. */ -const CLIENT_ID_VALUES = new Set(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode', 'a2a']); +const CLIENT_ID_VALUES = new Set(['anthropic', 'openai', 'google', 'kimi', 'dare', 'antigravity', 'opencode', 'a2a']); function collectCatIds(config: CatCafeConfig): Set { const catIds = new Set(); diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index 77d38afcb..33b30e994 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -62,7 +62,8 @@ const catVariantSchema = z.object({ variantLabel: z.string().min(1).optional(), // F32-b P4: disambiguation label mentionPatterns: z.array(mentionPatternSchema).optional(), // F32-b: variant-level mentions accountRef: z.string().min(1).optional(), // F127: concrete account binding - clientId: z.enum(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode', 'a2a']), + clientId: z.enum(['anthropic', 'openai', 'google', 'kimi', 'dare', 'antigravity', 'opencode', 'a2a']), + defaultModel: z.string().min(1), mcpSupport: z.boolean(), cli: cliConfigSchema, diff --git a/packages/api/src/config/env-registry.ts b/packages/api/src/config/env-registry.ts index 7602e39cc..eb6891bbf 100644 --- a/packages/api/src/config/env-registry.ts +++ b/packages/api/src/config/env-registry.ts @@ -24,6 +24,7 @@ export type EnvCategory = | 'codex' | 'dare' | 'gemini' + | 'kimi' | 'tts' | 'stt' | 'frontend' @@ -64,6 +65,7 @@ export const ENV_CATEGORIES: Record = { codex: '缅因猫 (Codex)', dare: '狸花猫 (Dare)', gemini: '暹罗猫 (Gemini)', + kimi: 'Kimi', tts: '语音合成 (TTS)', stt: '语音识别 (STT)', frontend: '前端', @@ -916,6 +918,50 @@ export const ENV_VARS: EnvDefinition[] = [ sensitive: false, }, + // --- kimi --- + { + name: 'MOONSHOT_API_KEY', + defaultValue: '(未设置)', + description: 'Kimi / Moonshot API Key(官方 kimi-cli API Key 模式用)', + category: 'kimi', + sensitive: true, + hubVisible: false, + }, + { + name: 'KIMI_SHARE_DIR', + defaultValue: '~/.kimi', + description: '官方 kimi-cli 共享目录(session / mcp / logs)', + category: 'kimi', + sensitive: false, + hubVisible: false, + }, + { + name: 'KIMI_CONFIG_FILE', + defaultValue: '~/.kimi/config.toml', + description: '官方 kimi-cli 配置文件路径(覆盖默认 ~/.kimi/config.toml)', + category: 'kimi', + sensitive: false, + hubVisible: false, + runtimeEditable: false, + }, + { + name: 'KIMI_AUTH_TOKEN', + defaultValue: '(未设置)', + description: 'Kimi 官方额度抓取用的 kimi-auth token(来自 kimi.com)', + category: 'quota', + sensitive: true, + hubVisible: false, + }, + { + name: 'KIMI_QUOTA_API_FALLBACK_ENABLED', + defaultValue: '0(默认关闭)', + description: '设为 1 允许 Kimi 额度在 CLI /usage 失败时降级到 API(仍需 KIMI_AUTH_TOKEN)', + category: 'quota', + sensitive: false, + hubVisible: false, + runtimeEditable: false, + }, + // --- tts --- { name: 'TTS_URL', @@ -1174,7 +1220,7 @@ export const ENV_VARS: EnvDefinition[] = [ { name: 'QUOTA_OFFICIAL_REFRESH_ENABLED', defaultValue: '0(默认关闭)', - description: '设为 1 允许官方额度抓取(需要 Chrome OAuth cookie)', + description: '设为 1 允许官方额度抓取(Claude/Codex OAuth + Kimi auth token)', category: 'quota', sensitive: false, }, diff --git a/packages/api/src/config/governance/governance-bootstrap.ts b/packages/api/src/config/governance/governance-bootstrap.ts index 1ec7e839e..bf6281ddd 100644 --- a/packages/api/src/config/governance/governance-bootstrap.ts +++ b/packages/api/src/config/governance/governance-bootstrap.ts @@ -28,6 +28,7 @@ const PROVIDER_FILES: Record = { claude: 'CLAUDE.md', codex: 'AGENTS.md', gemini: 'GEMINI.md', + kimi: 'KIMI.md', }; /** Provider skills directory mapping */ @@ -35,6 +36,7 @@ const PROVIDER_SKILLS_DIRS: Record = { claude: '.claude/skills', codex: '.codex/skills', gemini: '.gemini/skills', + kimi: '.kimi/skills', }; /** Provider hooks directory mapping (F070 Phase 2) */ @@ -42,6 +44,7 @@ const PROVIDER_HOOKS_DIRS: Record = { claude: '.claude/hooks', codex: '.codex/hooks', gemini: '.gemini/hooks', + kimi: '.kimi/hooks', }; export interface BootstrapOptions { @@ -70,7 +73,7 @@ export class GovernanceBootstrapService { actions.push(action); } - // 2. Skills symlinks for all 3 providers + // 2. Skills symlinks for all supported providers for (const [provider, skillsDir] of Object.entries(PROVIDER_SKILLS_DIRS) as [Provider, string][]) { const action = await this.symlinkSkills(targetProject, provider, skillsDir, opts.dryRun); actions.push(action); diff --git a/packages/api/src/config/governance/governance-pack.ts b/packages/api/src/config/governance/governance-pack.ts index 438b9df34..87b4a0bd0 100644 --- a/packages/api/src/config/governance/governance-pack.ts +++ b/packages/api/src/config/governance/governance-pack.ts @@ -2,7 +2,7 @@ * F070: Portable Governance Pack — content definitions * * Defines the managed block content that gets injected into - * external project CLAUDE.md/AGENTS.md/GEMINI.md files. + * external project CLAUDE.md/AGENTS.md/GEMINI.md/KIMI.md files. * * Port values use internal defaults (3001/6399/6398). * The sync-to-opensource pipeline transforms API/frontend ports @@ -42,12 +42,12 @@ const METHODOLOGY_INTRO = `### Knowledge Engineering - Feature lifecycle: kickoff → discussion → implementation → review → completion - SOP: See docs/SOP.md for the 6-step workflow`; -export type Provider = 'claude' | 'codex' | 'gemini'; +export type Provider = 'claude' | 'codex' | 'gemini' | 'kimi'; /** * Generate the managed block content for a specific provider. * This block is injected into the provider's instruction file - * (CLAUDE.md, AGENTS.md, or GEMINI.md). + * (CLAUDE.md, AGENTS.md, GEMINI.md, or KIMI.md). */ export function getGovernanceManagedBlock(provider: Provider): string { return [ diff --git a/packages/api/src/config/governance/governance-preflight.ts b/packages/api/src/config/governance/governance-preflight.ts index 971914c7c..90225d104 100644 --- a/packages/api/src/config/governance/governance-preflight.ts +++ b/packages/api/src/config/governance/governance-preflight.ts @@ -25,18 +25,21 @@ const CAT_PROVIDER_MAP: Record = { anthropic: 'claude', openai: 'codex', google: 'gemini', + kimi: 'kimi', }; const PROVIDER_CONFIG_FILE: Record = { claude: 'CLAUDE.md', codex: 'AGENTS.md', gemini: 'GEMINI.md', + kimi: 'KIMI.md', }; const PROVIDER_SKILLS_DIR: Record = { claude: '.claude/skills', codex: '.codex/skills', gemini: '.gemini/skills', + kimi: '.kimi/skills', }; export async function checkGovernancePreflight( @@ -73,7 +76,7 @@ export async function checkGovernancePreflight( const configFile = govProvider ? PROVIDER_CONFIG_FILE[govProvider] : 'CLAUDE.md'; const skillsDirs = govProvider ? [PROVIDER_SKILLS_DIR[govProvider]] - : ['.claude/skills', '.codex/skills', '.gemini/skills']; + : ['.claude/skills', '.codex/skills', '.gemini/skills', '.kimi/skills']; try { const content = await readFile(join(projectPath, configFile), 'utf-8'); diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index 499fda834..68efd6d20 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -697,6 +697,8 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP const isExplicitBindingCompatibilityError = (err: unknown): err is Error => err instanceof Error && (/bound provider profile/i.test(err.message) || /model ".+" is not available on provider/i.test(err.message)); + const isBoundAccountResolutionError = (err: unknown): err is Error => + err instanceof Error && /bound account ".+" not found/i.test(err.message); // Resolve account first, then use its protocol for env injection. // For API Key accounts, protocol is declared on the account itself. @@ -705,7 +707,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP try { resolvedAccount = assertCompatibleRuntimeAccount(await resolveRuntimeAccount()); } catch (err) { - if (isExplicitBindingCompatibilityError(err)) { + if (isExplicitBindingCompatibilityError(err) || isBoundAccountResolutionError(err)) { throw err; } if (boundAccountRef) { @@ -729,6 +731,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP anthropic: 'anthropic', openai: 'openai', google: 'google', + kimi: 'kimi', dare: 'openai', opencode: 'anthropic', openrouter: 'openai', @@ -812,6 +815,17 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP callbackEnv.GEMINI_BASE_URL = resolvedAccount.baseUrl; } } + } else if (effectiveProtocol === 'kimi') { + if (resolvedAccount?.authType === 'api_key' && resolvedAccount.apiKey) { + callbackEnv.CAT_CAFE_KIMI_PROFILE_MODE = 'api_key'; + callbackEnv.CAT_CAFE_KIMI_API_KEY = resolvedAccount.apiKey; + callbackEnv.MOONSHOT_API_KEY = resolvedAccount.apiKey; + if (resolvedAccount.baseUrl) { + callbackEnv.CAT_CAFE_KIMI_BASE_URL = resolvedAccount.baseUrl; + } + } else { + callbackEnv.CAT_CAFE_KIMI_PROFILE_MODE = 'subscription'; + } } else if (provider === 'anthropic' || provider === 'opencode') { // Fallback for unresolved accounts on anthropic/opencode providers callbackEnv.CAT_CAFE_ANTHROPIC_PROFILE_MODE = 'subscription'; diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index fa6d5a7d5..7fddf4a26 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -58,6 +58,8 @@ interface CodexAgentServiceOptions { rawArchive?: RawArchiveSink; /** Inject session context resolver (for testing) */ contextSnapshotResolver?: CodexSessionContextSnapshotResolver; + /** Override executable name/path for Codex-family CLIs. */ + cliCommand?: string; } type CodexAuthMode = 'oauth' | 'api_key' | 'auto'; @@ -222,6 +224,7 @@ export class CodexAgentService implements AgentService { private readonly auditLog: AuditLogSink; private readonly rawArchive: RawArchiveSink; private readonly contextSnapshotResolver: CodexSessionContextSnapshotResolver; + private readonly cliCommand: string; constructor(options?: CodexAgentServiceOptions) { this.catId = options?.catId ?? createCatId('codex'); @@ -230,6 +233,7 @@ export class CodexAgentService implements AgentService { this.auditLog = options?.auditLog ?? getEventAuditLog(); this.rawArchive = options?.rawArchive ?? new CliRawArchive(); this.contextSnapshotResolver = options?.contextSnapshotResolver ?? createCodexSessionContextSnapshotResolver(); + this.cliCommand = options?.cliCommand ?? 'codex'; } async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable { @@ -351,12 +355,12 @@ export class CodexAgentService implements AgentService { const semanticCompletionController = new AbortController(); - const codexCommand = resolveCliCommand('codex'); + const codexCommand = resolveCliCommand(this.cliCommand); if (!codexCommand) { yield { type: 'error' as const, catId: this.catId, - error: formatCliNotFoundError('codex'), + error: formatCliNotFoundError(this.cliCommand), metadata, timestamp: Date.now(), }; diff --git a/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts index 7c5099ed7..e64814ea1 100644 --- a/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts @@ -18,6 +18,9 @@ import { spawn as nodeSpawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; @@ -39,6 +42,105 @@ import { isKnownPostResponseCandidatesCrash, isResultErrorEvent, transformGemini const log = createModuleLogger('gemini-agent'); type GeminiAdapter = 'gemini-cli' | 'antigravity'; + +interface GeminiStoredThought { + readonly subject?: string; + readonly description?: string; +} + +interface GeminiStoredMessage { + readonly type?: string; + readonly content?: string; + readonly thoughts?: readonly GeminiStoredThought[]; +} + +interface GeminiStoredSession { + readonly sessionId?: string; + readonly messages?: readonly GeminiStoredMessage[]; +} + +function normalizeGeminiContent(value: string | undefined): string { + return (value ?? '').replace(/\s+/g, ' ').trim(); +} + +function formatGeminiThoughts(thoughts: readonly GeminiStoredThought[]): string { + return thoughts + .map((thought) => { + const subject = thought.subject?.trim(); + const description = thought.description?.trim(); + if (subject && description) return `**${subject}**\n${description}`; + if (subject) return `**${subject}**`; + if (description) return description; + return ''; + }) + .filter((chunk) => chunk.length > 0) + .join('\n\n---\n\n'); +} + +function readGeminiThinkingFromLocalSession( + sessionId: string | undefined, + assistantText: string, + workingDirectory?: string, +): string | null { + if (!sessionId) return null; + + const geminiTmpRoot = join(homedir(), '.gemini', 'tmp'); + if (!existsSync(geminiTmpRoot)) return null; + + const preferredProjectDir = workingDirectory ? basename(workingDirectory) : null; + const projectDirs = readdirSync(geminiTmpRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => { + if (preferredProjectDir && a === preferredProjectDir) return -1; + if (preferredProjectDir && b === preferredProjectDir) return 1; + return 0; + }); + + const normalizedAssistantText = normalizeGeminiContent(assistantText); + + for (const projectDir of projectDirs) { + const chatsDir = join(geminiTmpRoot, projectDir, 'chats'); + if (!existsSync(chatsDir)) continue; + + const sessionFiles = readdirSync(chatsDir) + .filter((name) => name.startsWith('session-') && name.endsWith('.json')) + .map((name) => ({ + path: join(chatsDir, name), + mtimeMs: statSync(join(chatsDir, name)).mtimeMs, + })) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const file of sessionFiles) { + try { + const parsed = JSON.parse(readFileSync(file.path, 'utf8')) as GeminiStoredSession; + if (parsed.sessionId !== sessionId || !Array.isArray(parsed.messages)) continue; + + const candidates = parsed.messages.filter( + (message): message is GeminiStoredMessage => + message?.type === 'gemini' && + Array.isArray(message.thoughts) && + message.thoughts.length > 0 && + typeof message.content === 'string', + ); + if (candidates.length === 0) return null; + + const exact = + normalizedAssistantText.length > 0 + ? [...candidates] + .reverse() + .find((message) => normalizeGeminiContent(message.content) === normalizedAssistantText) + : candidates[candidates.length - 1]; + const selected = exact ?? null; + return selected ? formatGeminiThoughts(selected.thoughts ?? []) || null : null; + } catch { + // Best effort: skip malformed/partial session files while Gemini is still writing them. + } + } + } + + return null; +} /** * Options for constructing GeminiAgentService (dependency injection) * F32-b: catId and model are constructor parameters @@ -124,6 +226,7 @@ export class GeminiAgentService implements AgentService { let sawResultError = false; let sawAssistantText = false; let suppressCliExitError = false; + let fullAssistantText = ''; const cliOpts = { command: geminiCommand, args, @@ -233,8 +336,10 @@ export class GeminiAgentService implements AgentService { // Each Gemini message/assistant is a complete turn (unlike Claude's // incremental deltas), so direct concatenation loses inter-turn spacing. if (sawAssistantText && result.content) { + fullAssistantText += `\n\n${result.content}`; yield { ...result, content: `\n\n${result.content}`, metadata }; } else { + fullAssistantText += result.content ?? ''; yield { ...result, metadata }; } sawAssistantText = true; @@ -247,6 +352,21 @@ export class GeminiAgentService implements AgentService { } } + const thinking = readGeminiThinkingFromLocalSession( + metadata.sessionId, + fullAssistantText, + options?.workingDirectory, + ); + if (thinking) { + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ type: 'thinking', catId: this.catId, text: thinking }), + metadata, + timestamp: Date.now(), + }; + } + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; } catch (err) { yield { diff --git a/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts new file mode 100644 index 000000000..aa207a3fa --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts @@ -0,0 +1,377 @@ +/** Kimi Agent Service — kimi-cli subprocess via print mode + stream-json. */ + +import { rmSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { type CatId, createCatId } from '@cat-cafe/shared'; +import { getCatModel } from '../../../../../config/cat-models.js'; +import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import { formatCliExitError } from '../../../../../utils/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; +import type { SpawnFn } from '../../../../../utils/cli-types.js'; +import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata } from '../../types.js'; +import { resolveDefaultClaudeMcpServerPath } from './ClaudeAgentService.js'; +import { collectImageAccessDirectories } from './image-cli-bridge.js'; +import { extractImagePaths } from './image-paths.js'; +import { + buildApiKeyEnv, + buildProjectMcpArgs, + readKimiContextUsedTokens, + readKimiModelConfigInfo, + readKimiSessionId, + resolveKimiModelAlias, + writeMcpConfigFile, +} from './kimi-config.js'; +import { + buildKimiPrompt, + extractTextContent, + extractThinkingContent, + type KimiPrintMessage, + parseToolArguments, + parseUsage, + readSessionIdFromMessage, +} from './kimi-event-parser.js'; + +const log = createModuleLogger('kimi-agent'); + +interface KimiAgentServiceOptions { + catId?: CatId; + spawnFn?: SpawnFn; + model?: string; + mcpServerPath?: string; +} + +export class KimiAgentService implements AgentService { + readonly catId: CatId; + private readonly spawnFn: SpawnFn | undefined; + private readonly model: string; + private readonly mcpServerPath: string | undefined; + + constructor(options?: KimiAgentServiceOptions) { + this.catId = options?.catId ?? createCatId('kimi'); + this.spawnFn = options?.spawnFn; + this.model = options?.model ?? getCatModel(this.catId as string); + this.mcpServerPath = + options?.mcpServerPath ?? process.env.CAT_CAFE_MCP_SERVER_PATH ?? resolveDefaultClaudeMcpServerPath(); + } + + async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable { + const requestedModel = options?.callbackEnv?.CAT_CAFE_KIMI_MODEL_OVERRIDE ?? this.model; + const effectiveModel = resolveKimiModelAlias(requestedModel, options?.callbackEnv); + const metadata: MessageMetadata = { provider: 'kimi', model: effectiveModel }; + const imagePaths = extractImagePaths(options?.contentBlocks, options?.uploadDir); + const imageAccessDirs = collectImageAccessDirectories(imagePaths); + const effectivePrompt = buildKimiPrompt(prompt, options?.systemPrompt, imagePaths); + const workingDirectory = options?.workingDirectory ?? process.cwd(); + const apiKeyEnv = buildApiKeyEnv(effectiveModel, options?.callbackEnv); + const tempMcpConfig = this.mcpServerPath + ? writeMcpConfigFile(workingDirectory, this.mcpServerPath, options?.callbackEnv) + : null; + const modelConfig = readKimiModelConfigInfo(effectiveModel, options?.callbackEnv); + const supportsThinking = + modelConfig.capabilities.includes('thinking') || + apiKeyEnv?.KIMI_MODEL_CAPABILITIES?.includes('thinking') === true; + const supportsImageInput = + modelConfig.capabilities.includes('image_in') || + apiKeyEnv?.KIMI_MODEL_CAPABILITIES?.includes('image_in') === true; + + const args = ['--print', '--output-format', 'stream-json']; + if (options?.sessionId) { + args.push('--session', options.sessionId); + metadata.sessionId = options.sessionId; + yield { + type: 'session_init', + catId: this.catId, + sessionId: options.sessionId, + metadata, + timestamp: Date.now(), + }; + } + args.push('--work-dir', workingDirectory); + if (supportsThinking || modelConfig.defaultThinking) { + args.push('--thinking'); + } + if (tempMcpConfig) { + args.push('--mcp-config-file', tempMcpConfig); + } else { + args.push(...buildProjectMcpArgs(workingDirectory)); + } + for (const dir of imageAccessDirs) { + args.push('--add-dir', dir); + } + if (!apiKeyEnv) { + args.push('--model', effectiveModel); + } + args.push('--prompt', effectivePrompt); + + try { + const kimiCommand = resolveCliCommand('kimi'); + if (!kimiCommand) { + yield { + type: 'error' as const, + catId: this.catId, + error: formatCliNotFoundError('kimi'), + metadata, + timestamp: Date.now(), + }; + yield { type: 'done' as const, catId: this.catId, metadata, timestamp: Date.now() }; + return; + } + + let emittedSessionInit = Boolean(options?.sessionId); + let sawThinking = false; + let emittedImageCapability = false; + const cliOpts = { + command: kimiCommand, + args, + ...(options?.workingDirectory ? { cwd: options.workingDirectory } : {}), + ...(options?.callbackEnv || apiKeyEnv + ? { env: { ...(options?.callbackEnv ?? {}), ...(apiKeyEnv ?? {}) } } + : {}), + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.invocationId ? { invocationId: options.invocationId } : {}), + ...(options?.cliSessionId ? { cliSessionId: options.cliSessionId } : {}), + ...(options?.livenessProbe ? { livenessProbe: options.livenessProbe } : {}), + }; + const events = options?.spawnCliOverride + ? options.spawnCliOverride(cliOpts) + : spawnCli(cliOpts, this.spawnFn ? { spawnFn: this.spawnFn } : undefined); + + for await (const event of events) { + if (isCliTimeout(event)) { + const { + silenceDurationMs, + processAlive, + lastEventType, + firstEventAt, + lastEventAt, + cliSessionId: csId, + invocationId: invId, + rawArchivePath, + } = event; + yield { + type: 'system_info' as const, + catId: this.catId, + timestamp: Date.now(), + content: JSON.stringify({ + type: 'timeout_diagnostics', + silenceDurationMs, + processAlive, + lastEventType, + firstEventAt, + lastEventAt, + cliSessionId: csId, + invocationId: invId, + rawArchivePath, + }), + }; + yield { + type: 'error', + catId: this.catId, + metadata, + timestamp: Date.now(), + error: `Kimi CLI 响应超时 (${Math.round(event.timeoutMs / 1000)}s${firstEventAt == null ? ', 未收到首帧' : ''})`, + }; + continue; + } + if (isLivenessWarning(event)) { + const w = event as { level?: string; silenceDurationMs?: number }; + log.warn( + { catId: this.catId, invocationId: options?.invocationId, level: w.level, silenceMs: w.silenceDurationMs }, + '[KimiAgent] liveness warning — CLI may be stuck', + ); + yield { + type: 'system_info' as const, + catId: this.catId, + timestamp: Date.now(), + content: JSON.stringify({ type: 'liveness_warning', ...event }), + }; + continue; + } + if (isCliError(event)) { + yield { + type: 'error', + catId: this.catId, + error: formatCliExitError('Kimi CLI', event), + metadata, + timestamp: Date.now(), + }; + continue; + } + + if ( + event && + typeof event === 'object' && + 'line' in event && + typeof (event as { line?: unknown }).line === 'string' && + !emittedSessionInit + ) { + const line = (event as { line: string }).line; + const match = line.match(/To resume this session:\s*kimi\s+-r\s+([a-z0-9-]+)/i); + if (match?.[1]) { + metadata.sessionId = match[1]; + emittedSessionInit = true; + yield { + type: 'session_init', + catId: this.catId, + sessionId: match[1], + metadata: { ...metadata, sessionId: match[1] }, + timestamp: Date.now(), + }; + } + continue; + } + + const msg = event as KimiPrintMessage; + if (msg?.role !== 'assistant') continue; + + const usage = parseUsage(msg.usage) ?? parseUsage(msg.stats); + if (usage) metadata.usage = { ...(metadata.usage ?? {}), ...usage }; + + const messageSessionId = readSessionIdFromMessage(msg); + if (messageSessionId) { + metadata.sessionId = messageSessionId; + if (!emittedSessionInit) { + emittedSessionInit = true; + yield { + type: 'session_init', + catId: this.catId, + sessionId: messageSessionId, + metadata, + timestamp: Date.now(), + }; + } + } + + const thinking = extractThinkingContent(msg); + if (thinking) { + sawThinking = true; + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ type: 'thinking', catId: this.catId, text: thinking }), + metadata, + timestamp: Date.now(), + }; + } + + if (imagePaths.length > 0 && !emittedImageCapability) { + emittedImageCapability = true; + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ + type: 'provider_capability', + capability: 'image_input', + status: supportsImageInput ? 'available' : 'limited', + provider: 'kimi', + reason: supportsImageInput + ? '已通过工作区附加目录 + 本地路径提示向 kimi-cli 暴露图片输入' + : '当前 Kimi 模型未声明 image_in,已回退为本地路径提示', + }), + metadata, + timestamp: Date.now(), + }; + } + + const content = extractTextContent(msg.content); + if (content) { + yield { + type: 'text', + catId: this.catId, + content, + metadata, + timestamp: Date.now(), + }; + } + + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== 'object') continue; + const call = toolCall as Record; + const fn = call.function; + if (!fn || typeof fn !== 'object') continue; + const functionCall = fn as Record; + const toolName = typeof functionCall.name === 'string' ? functionCall.name : null; + if (!toolName) continue; + yield { + type: 'tool_use', + catId: this.catId, + toolName, + toolInput: parseToolArguments(functionCall.arguments), + metadata, + timestamp: Date.now(), + }; + } + } + + if (!emittedSessionInit) { + const inferredSessionId = readKimiSessionId(workingDirectory, options?.callbackEnv); + if (inferredSessionId) { + metadata.sessionId = inferredSessionId; + emittedSessionInit = true; + yield { + type: 'session_init', + catId: this.catId, + sessionId: inferredSessionId, + metadata: { ...metadata, sessionId: inferredSessionId }, + timestamp: Date.now(), + }; + } + } + + if (metadata.sessionId && modelConfig.maxContextSize != null) { + try { + const contextUsedTokens = await readKimiContextUsedTokens(metadata.sessionId, options?.callbackEnv); + if (contextUsedTokens != null) { + metadata.usage = { + ...(metadata.usage ?? {}), + contextUsedTokens, + contextWindowSize: modelConfig.maxContextSize, + lastTurnInputTokens: contextUsedTokens, + }; + } + } catch { + // best-effort snapshot enrichment only + } + } + + if (!sawThinking) { + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ + type: 'provider_capability', + capability: 'thinking', + status: 'unavailable', + provider: 'kimi', + reason: supportsThinking + ? 'kimi-cli 本次流式输出未提供可解析的 think/reasoning 内容' + : '当前 Kimi 模型能力未声明 thinking,已按普通回答处理', + }), + metadata, + timestamp: Date.now(), + }; + } + + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; + } catch (err) { + yield { + type: 'error', + catId: this.catId, + error: err instanceof Error ? err.message : String(err), + metadata, + timestamp: Date.now(), + }; + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; + } finally { + if (tempMcpConfig) { + try { + rmSync(dirname(tempMcpConfig), { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + } + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/kimi-config.ts b/packages/api/src/domains/cats/services/agents/providers/kimi-config.ts new file mode 100644 index 000000000..11a42990f --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/kimi-config.ts @@ -0,0 +1,267 @@ +/** + * Kimi CLI configuration, path resolution, and session utilities + * + * Reads config.toml / kimi.json, resolves model aliases, normalizes workdir + * paths, and provides session/context reading helpers. + */ + +import { existsSync, promises as fs, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, normalize, resolve } from 'node:path'; + +export const DEFAULT_KIMI_BASE_URL = 'https://api.moonshot.ai/v1'; +export const DEFAULT_KIMI_MODEL_ALIAS = 'kimi-code/kimi-for-coding'; + +export const CAT_CAFE_CALLBACK_ENV_KEYS = [ + 'CAT_CAFE_API_URL', + 'CAT_CAFE_INVOCATION_ID', + 'CAT_CAFE_CALLBACK_TOKEN', + 'CAT_CAFE_USER_ID', + 'CAT_CAFE_THREAD_ID', + 'CAT_CAFE_RUN_TYPE', + 'CAT_CAFE_AUDIT_TOPIC', +]; + +const KIMI_CONTEXT_TAIL_BYTES = 64 * 1024; + +export interface KimiModelConfigInfo { + defaultThinking: boolean; + capabilities: string[]; + maxContextSize?: number; +} + +export function resolveKimiShareDir(callbackEnv?: Record): string { + return callbackEnv?.KIMI_SHARE_DIR || process.env.KIMI_SHARE_DIR || resolve(homedir(), '.kimi'); +} + +export function resolveKimiConfigPath(callbackEnv?: Record): string { + const explicit = callbackEnv?.KIMI_CONFIG_FILE || process.env.KIMI_CONFIG_FILE; + if (explicit) return resolve(explicit); + return join(resolveKimiShareDir(callbackEnv), 'config.toml'); +} + +export function normalizeKimiWorkDirPath(candidate: string): string { + const resolved = resolve(candidate); + try { + return normalize(realpathSync(resolved)); + } catch { + return normalize(resolved); + } +} + +export function readKimiModelConfigInfo(modelAlias: string, callbackEnv?: Record): KimiModelConfigInfo { + const fallbackCapabilities: string[] = + modelAlias === DEFAULT_KIMI_MODEL_ALIAS ? ['thinking', 'image_in', 'video_in'] : []; + const configPath = resolveKimiConfigPath(callbackEnv); + if (!existsSync(configPath)) { + return { + defaultThinking: fallbackCapabilities.includes('thinking'), + capabilities: [...fallbackCapabilities], + ...(modelAlias === DEFAULT_KIMI_MODEL_ALIAS ? { maxContextSize: 262_144 } : {}), + }; + } + + try { + const raw = readFileSync(configPath, 'utf-8'); + const defaultThinkingMatch = raw.match(/^\s*default_thinking\s*=\s*(true|false)\s*$/m); + const sectionHeader = `[models."${modelAlias}"]`; + const sectionStart = raw.indexOf(sectionHeader); + let capabilities: string[] = [...fallbackCapabilities]; + let maxContextSize: number | undefined = modelAlias === DEFAULT_KIMI_MODEL_ALIAS ? 262_144 : undefined; + if (sectionStart >= 0) { + const nextSection = raw.indexOf('\n[', sectionStart + sectionHeader.length); + const section = raw.slice(sectionStart, nextSection >= 0 ? nextSection : undefined); + const capsMatch = section.match(/^\s*capabilities\s*=\s*\[([^\]]*)\]/m); + const maxContextMatch = section.match(/^\s*max_context_size\s*=\s*(\d+)\s*$/m); + if (capsMatch?.[1]) { + capabilities = Array.from( + new Set( + capsMatch[1] + .split(',') + .map((item) => item.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean), + ), + ); + } + if (maxContextMatch?.[1]) { + const parsed = Number.parseInt(maxContextMatch[1], 10); + if (Number.isFinite(parsed) && parsed > 0) maxContextSize = parsed; + } + } + return { + defaultThinking: + defaultThinkingMatch?.[1] === 'true' || + capabilities.includes('thinking') || + fallbackCapabilities.includes('thinking'), + capabilities, + ...(maxContextSize ? { maxContextSize } : {}), + }; + } catch { + return { + defaultThinking: fallbackCapabilities.includes('thinking'), + capabilities: [...fallbackCapabilities], + ...(modelAlias === DEFAULT_KIMI_MODEL_ALIAS ? { maxContextSize: 262_144 } : {}), + }; + } +} + +export function resolveKimiModelAlias(model: string, callbackEnv?: Record): string { + if (callbackEnv?.CAT_CAFE_KIMI_API_KEY) return model; + if (model.includes('/')) return model; + + const configPath = resolveKimiConfigPath(callbackEnv); + if (existsSync(configPath)) { + try { + const raw = readFileSync(configPath, 'utf-8'); + const match = raw.match(/^\s*default_model\s*=\s*["']([^"']+)["']/m); + if (match?.[1]) return match[1].trim(); + } catch { + // Fall through to baked-in alias. + } + } + + return DEFAULT_KIMI_MODEL_ALIAS; +} + +export function readKimiSessionId(workingDirectory: string, callbackEnv?: Record): string | undefined { + const shareDir = resolveKimiShareDir(callbackEnv); + const statePath = join(shareDir, 'kimi.json'); + if (!existsSync(statePath)) return undefined; + try { + const raw = JSON.parse(readFileSync(statePath, 'utf-8')) as { work_dirs?: Array> }; + const workDirs = Array.isArray(raw?.work_dirs) ? raw.work_dirs : []; + const target = normalizeKimiWorkDirPath(workingDirectory); + const entry = workDirs.find( + (item) => typeof item.path === 'string' && normalizeKimiWorkDirPath(item.path) === target, + ); + return typeof entry?.last_session_id === 'string' && entry.last_session_id.trim().length > 0 + ? entry.last_session_id + : undefined; + } catch { + return undefined; + } +} + +export function buildProjectMcpArgs(workingDirectory?: string): string[] { + if (!workingDirectory) return []; + const mcpConfigPath = join(workingDirectory, '.kimi', 'mcp.json'); + return existsSync(mcpConfigPath) ? ['--mcp-config-file', mcpConfigPath] : []; +} + +async function readTailUtf8(filePath: string, maxBytes: number): Promise { + const handle = await fs.open(filePath, 'r'); + try { + const stat = await handle.stat(); + const readBytes = Math.min(stat.size, maxBytes); + if (readBytes <= 0) return ''; + const buffer = Buffer.alloc(readBytes); + await handle.read(buffer, 0, readBytes, stat.size - readBytes); + return buffer.toString('utf8'); + } finally { + await handle.close(); + } +} + +async function findKimiSessionContextFile(shareDir: string, sessionId: string): Promise { + const sessionsRoot = join(shareDir, 'sessions'); + const stack: string[] = [sessionsRoot]; + while (stack.length > 0) { + const dir = stack.pop(); + if (!dir) break; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + continue; + } + for (const entry of entries) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === sessionId) { + const contextFile = join(abs, 'context.jsonl'); + try { + await fs.access(contextFile); + return contextFile; + } catch { + return null; + } + } + stack.push(abs); + } + } + } + return null; +} + +export async function readKimiContextUsedTokens( + sessionId: string, + callbackEnv?: Record, +): Promise { + const contextFile = await findKimiSessionContextFile(resolveKimiShareDir(callbackEnv), sessionId); + if (!contextFile) return undefined; + const tail = await readTailUtf8(contextFile, KIMI_CONTEXT_TAIL_BYTES); + if (!tail) return undefined; + const lines = tail.split('\n'); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]?.trim(); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + if (parsed.role === '_usage' && typeof parsed.token_count === 'number' && Number.isFinite(parsed.token_count)) { + return parsed.token_count; + } + } catch {} + } + return undefined; +} + +export function buildApiKeyEnv(model: string, callbackEnv?: Record): Record | null { + const apiKey = callbackEnv?.CAT_CAFE_KIMI_API_KEY; + if (!apiKey) return null; + const baseUrl = callbackEnv?.CAT_CAFE_KIMI_BASE_URL || DEFAULT_KIMI_BASE_URL; + const configuredModelName = model.trim(); + return { + KIMI_API_KEY: apiKey, + KIMI_BASE_URL: baseUrl, + KIMI_MODEL_NAME: configuredModelName, + KIMI_MODEL_MAX_CONTEXT_SIZE: callbackEnv?.KIMI_MODEL_MAX_CONTEXT_SIZE || '262144', + ...(callbackEnv?.KIMI_MODEL_CAPABILITIES ? { KIMI_MODEL_CAPABILITIES: callbackEnv.KIMI_MODEL_CAPABILITIES } : {}), + }; +} + +export function writeMcpConfigFile( + workingDirectory: string, + mcpServerPath: string, + callbackEnv?: Record, +): string | null { + if (!callbackEnv || !mcpServerPath) return null; + const existingPath = join(workingDirectory, '.kimi', 'mcp.json'); + let config: Record = {}; + if (existsSync(existingPath)) { + try { + const raw = JSON.parse(readFileSync(existingPath, 'utf-8')) as Record; + config = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + } catch { + config = {}; + } + } + const currentServers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + const catCafeEnv = Object.fromEntries( + CAT_CAFE_CALLBACK_ENV_KEYS.map((key) => [key, callbackEnv[key]]).filter(([, value]) => Boolean(value)), + ); + currentServers['cat-cafe'] = { + command: 'node', + args: [mcpServerPath], + ...(Object.keys(catCafeEnv).length > 0 ? { env: catCafeEnv } : {}), + }; + const nextConfig = { ...config, mcpServers: currentServers }; + const shareDir = resolveKimiShareDir(callbackEnv); + mkdirSync(shareDir, { recursive: true }); + const dir = mkdtempSync(join(shareDir, 'tmp-mcp-')); + const path = join(dir, 'mcp.json'); + writeFileSync(path, JSON.stringify(nextConfig), { encoding: 'utf8', mode: 0o600 }); + return path; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/kimi-event-parser.ts b/packages/api/src/domains/cats/services/agents/providers/kimi-event-parser.ts new file mode 100644 index 000000000..7b4409d6a --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/kimi-event-parser.ts @@ -0,0 +1,114 @@ +/** + * Kimi CLI stream-json event parsing utilities + * + * Extracts text, thinking content, tool calls, usage stats, and session IDs + * from the Kimi CLI `--output-format stream-json` output. + */ + +import type { TokenUsage } from '../../types.js'; +import { appendLocalImagePathHints } from './image-cli-bridge.js'; + +export interface KimiPrintMessage { + role?: string; + content?: unknown; + thinking?: unknown; + reasoning?: unknown; + reasoning_content?: unknown; + thought?: unknown; + tool_calls?: unknown[]; + usage?: unknown; + stats?: unknown; + session_id?: string; + sessionId?: string; +} + +export function parseToolArguments(raw: unknown): Record { + if (typeof raw !== 'string' || raw.trim().length === 0) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return { raw }; + } +} + +export function extractTextContent(content: unknown): string | null { + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (!Array.isArray(content)) return null; + const text = content + .map((item) => { + if (typeof item === 'string') return item; + if (!item || typeof item !== 'object') return ''; + const block = item as Record; + if (typeof block.text === 'string') return block.text; + if (typeof block.content === 'string') return block.content; + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); + return text.length > 0 ? text : null; +} + +export function extractThinkingContent(message: KimiPrintMessage): string | null { + const candidates = [message.thinking, message.reasoning, message.reasoning_content, message.thought]; + for (const candidate of candidates) { + const text = extractTextContent(candidate); + if (text) return text; + } + if (Array.isArray(message.content)) { + const thinkText = message.content + .map((item) => { + if (!item || typeof item !== 'object') return ''; + const block = item as Record; + if (typeof block.think === 'string') return block.think; + if (typeof block.reasoning === 'string') return block.reasoning; + if (block.type === 'thinking' && typeof block.text === 'string') return block.text; + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); + if (thinkText) return thinkText; + } + return null; +} + +export function parseUsage(candidate: unknown): TokenUsage | null { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null; + const stats = candidate as Record; + const usage = {} as TokenUsage; + if (typeof stats.total_tokens === 'number') usage.totalTokens = stats.total_tokens; + if (typeof stats.input_tokens === 'number') usage.inputTokens = stats.input_tokens; + if (typeof stats.output_tokens === 'number') usage.outputTokens = stats.output_tokens; + if (typeof stats.cached_input_tokens === 'number') usage.cacheReadTokens = stats.cached_input_tokens; + if (typeof stats.last_turn_input_tokens === 'number') usage.lastTurnInputTokens = stats.last_turn_input_tokens; + if (typeof stats.context_window === 'number') usage.contextWindowSize = stats.context_window; + if (typeof stats.context_used_tokens === 'number') usage.contextUsedTokens = stats.context_used_tokens; + return Object.keys(usage).length > 0 ? usage : null; +} + +export function readSessionIdFromMessage(message: KimiPrintMessage): string | undefined { + const values = [message.session_id, message.sessionId]; + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) return value; + } + return undefined; +} + +export function buildKimiPrompt(prompt: string, systemPrompt?: string, imagePaths: readonly string[] = []): string { + const basePrompt = appendLocalImagePathHints(prompt, imagePaths); + if (!systemPrompt?.trim()) return basePrompt; + return [ + '', + systemPrompt.trim(), + '', + '', + '', + basePrompt, + '', + ].join('\n'); +} diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index 7f0701e87..7c0b3b8f8 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -26,6 +26,7 @@ const TOKEN_BOUNDARY_RE = /[\s,.:;!?()[\]{}<>,。!?、:;()【】 // If the next char looks like part of a handle token, treat it as NOT a boundary. // This avoids prefix-matching `@opus-45` as `@opus`, while still allowing `@opus请看`. const HANDLE_CONTINUATION_RE = /[a-z0-9_.-]/; +const LEADING_MARKDOWN_MENTION_PREFIX_RE = /^(?:(?:>\s*)|(?:[-*+]\s+)|(?:\d+[.)]\s+))+/; interface MentionPatternEntry { readonly catId: CatId; @@ -104,21 +105,38 @@ export function analyzeA2AMentions( if (found.length >= MAX_A2A_MENTION_TARGETS) break; // 5. Safety limit const leadingWs = rawLine.match(/^\s*/)?.[0].length ?? 0; - const normalized = rawLine.slice(leadingWs).toLowerCase(); + const normalized = rawLine.slice(leadingWs).toLowerCase().replace(LEADING_MARKDOWN_MENTION_PREFIX_RE, ''); if (!normalized.startsWith('@')) { continue; } - for (const entry of entries) { - if (!normalized.startsWith(entry.pattern)) continue; - const charAfter = normalized[entry.pattern.length]; - const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); - if (!isBoundary) continue; - if (!seen.has(entry.catId)) { - seen.add(entry.catId); - found.push(entry.catId); + let cursor = 0; + while (cursor < normalized.length && found.length < MAX_A2A_MENTION_TARGETS) { + const segment = normalized.slice(cursor); + let matched = false; + + for (const entry of entries) { + if (!segment.startsWith(entry.pattern)) continue; + const charAfter = segment[entry.pattern.length]; + const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); + if (!isBoundary) continue; + if (!seen.has(entry.catId)) { + seen.add(entry.catId); + found.push(entry.catId); + } + cursor += entry.pattern.length; + matched = true; + break; // longest-match-first: lock one winner at current cursor + } + + if (!matched) break; + + while (cursor < normalized.length && TOKEN_BOUNDARY_RE.test(normalized[cursor]!)) { + cursor += 1; + } + if (normalized[cursor] !== '@') { + break; } - break; // longest-match-first: lock one winner for this line } } diff --git a/packages/api/src/domains/cats/services/bootcamp/env-check.ts b/packages/api/src/domains/cats/services/bootcamp/env-check.ts index 12b5456b1..1b12304a2 100644 --- a/packages/api/src/domains/cats/services/bootcamp/env-check.ts +++ b/packages/api/src/domains/cats/services/bootcamp/env-check.ts @@ -20,6 +20,9 @@ export interface EnvCheckResult { pnpm: EnvCheckItem; git: EnvCheckItem; claudeCli: EnvCheckItem; + codexCli: EnvCheckItem; + geminiCli: EnvCheckItem; + kimiCli: EnvCheckItem; mcp: EnvCheckItem; tts: { ok: boolean; recommended: string }; asr: { ok: boolean }; @@ -51,11 +54,14 @@ async function checkPort(port: number): Promise { } export async function runEnvironmentCheck(): Promise { - const [node, pnpm, git, claudeCli] = await Promise.all([ + const [node, pnpm, git, claudeCli, codexCli, geminiCli, kimiCli] = await Promise.all([ checkCommand('node --version'), checkCommand('pnpm --version'), checkCommand('git --version'), checkCommand('claude --version'), + checkCommand('codex --version'), + checkCommand('gemini --version'), + checkCommand('kimi --version'), ]); const mcpPath = process.env.CAT_CAFE_MCP_SERVER_PATH || resolveDefaultClaudeMcpServerPath(); @@ -70,6 +76,9 @@ export async function runEnvironmentCheck(): Promise { pnpm, git, claudeCli, + codexCli, + geminiCli, + kimiCli, mcp, tts: { ok: ttsPort, diff --git a/packages/api/src/domains/cats/services/game/LlmAIProvider.ts b/packages/api/src/domains/cats/services/game/LlmAIProvider.ts index 1ff98d359..c07893b50 100644 --- a/packages/api/src/domains/cats/services/game/LlmAIProvider.ts +++ b/packages/api/src/domains/cats/services/game/LlmAIProvider.ts @@ -54,6 +54,8 @@ export class LlmAIProvider implements AIProvider { return await this.callOpenAI(prompt, controller.signal); case 'google': return await this.callGoogle(prompt, controller.signal); + case 'kimi': + return await this.callKimi(prompt, controller.signal); default: // Unsupported providers (dare, antigravity, etc.) — fall through to Anthropic return await this.callAnthropic(prompt, controller.signal); @@ -64,7 +66,7 @@ export class LlmAIProvider implements AIProvider { } /** Resolve API key via full account discovery chain (well-known → builtin_ → installer-). */ - private resolveApiKey(client: 'anthropic' | 'openai' | 'google'): string | undefined { + private resolveApiKey(client: 'anthropic' | 'openai' | 'google' | 'kimi'): string | undefined { const profile = resolveForClient(process.cwd(), client); return profile?.apiKey; } @@ -150,6 +152,33 @@ export class LlmAIProvider implements AIProvider { return { text: data.candidates[0]?.content.parts[0]?.text ?? '' }; } + private async callKimi(prompt: string, signal: AbortSignal): Promise { + const apiKey = this.resolveApiKey('kimi'); + if (!apiKey) throw new Error('No Kimi API key in credentials or MOONSHOT_API_KEY env'); + + const resp = await fetch('https://api.moonshot.ai/v1/chat/completions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: 256, + messages: [{ role: 'user', content: prompt }], + }), + signal, + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`Kimi API error ${resp.status}: ${body.slice(0, 200)}`); + } + + const data = (await resp.json()) as { choices: Array<{ message: { content: string } }> }; + return { text: data.choices[0]?.message.content ?? '' }; + } + /** Parse LLM text response into structured action. Tolerates markdown wrapping. */ private parseActionResponse(text: string): AIActionResponse { // Strip markdown code fences if present diff --git a/packages/api/src/domains/cats/services/index.ts b/packages/api/src/domains/cats/services/index.ts index 56021b23b..6c9ea0046 100644 --- a/packages/api/src/domains/cats/services/index.ts +++ b/packages/api/src/domains/cats/services/index.ts @@ -12,6 +12,7 @@ export { ClaudeAgentService } from './agents/providers/ClaudeAgentService.js'; export { CodexAgentService } from './agents/providers/CodexAgentService.js'; export { DareAgentService } from './agents/providers/DareAgentService.js'; export { GeminiAgentService } from './agents/providers/GeminiAgentService.js'; +export { KimiAgentService } from './agents/providers/KimiAgentService.js'; export { OpenCodeAgentService } from './agents/providers/OpenCodeAgentService.js'; export { AgentRegistry } from './agents/registry/AgentRegistry.js'; export type { AgentRouterOptions } from './agents/routing/AgentRouter.js'; diff --git a/packages/api/src/domains/cats/services/usage-aggregator.ts b/packages/api/src/domains/cats/services/usage-aggregator.ts index fbcc1b37a..9772c39a9 100644 --- a/packages/api/src/domains/cats/services/usage-aggregator.ts +++ b/packages/api/src/domains/cats/services/usage-aggregator.ts @@ -4,6 +4,7 @@ */ import type { InvocationRecord } from './stores/ports/InvocationRecordStore.js'; +import type { TokenUsage } from './types.js'; /** Aggregated token stats for a single cat on a single day */ export interface CatDailyUsage { @@ -64,6 +65,22 @@ function toDateString(epochMs: number): string { return new Date(epochMs).toISOString().slice(0, 10); } +function resolveInputTokens(usage: TokenUsage): number { + if (typeof usage.inputTokens === 'number') return usage.inputTokens; + if (typeof usage.lastTurnInputTokens === 'number') return usage.lastTurnInputTokens; + if (typeof usage.contextUsedTokens === 'number') return usage.contextUsedTokens; + if (typeof usage.totalTokens === 'number') return usage.totalTokens; + return 0; +} + +function resolveOutputTokens(usage: TokenUsage, resolvedInputTokens: number): number { + if (typeof usage.outputTokens === 'number') return usage.outputTokens; + if (typeof usage.totalTokens === 'number') { + return Math.max(0, usage.totalTokens - resolvedInputTokens); + } + return 0; +} + /** * Aggregate invocation records into a daily-by-cat usage report. * Pure function — no side effects, no I/O. @@ -102,8 +119,9 @@ export function aggregateUsageByDay(records: InvocationRecord[], options: Aggreg } const existing = dayBucket.get(catId) ?? emptyCatUsage(); - existing.inputTokens += usage.inputTokens ?? 0; - existing.outputTokens += usage.outputTokens ?? 0; + const resolvedInputTokens = resolveInputTokens(usage); + existing.inputTokens += resolvedInputTokens; + existing.outputTokens += resolveOutputTokens(usage, resolvedInputTokens); existing.cacheReadTokens += usage.cacheReadTokens ?? 0; existing.costUsd += usage.costUsd ?? 0; existing.participations += 1; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3afacf505..a357e1467 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -49,6 +49,7 @@ import { DeliveryCursorStore, GeminiAgentService, getEventAuditLog, + KimiAgentService, MemoryGovernanceStore, OpenCodeAgentService, } from './domains/cats/services/index.js'; @@ -792,6 +793,9 @@ async function main(): Promise { } break; } + case 'kimi': + service = new KimiAgentService({ catId }); + break; case 'dare': service = new DareAgentService({ catId }); break; @@ -1617,6 +1621,7 @@ async function main(): Promise { anthropic: join(root, '.mcp.json'), openai: join(root, '.codex', 'config.toml'), google: join(root, '.gemini', 'settings.json'), + kimi: join(root, '.kimi', 'mcp.json'), }); app.log.info('[api] CLI configs regenerated at startup'); } diff --git a/packages/api/src/routes/accounts.ts b/packages/api/src/routes/accounts.ts index 56c365594..c292e14d8 100644 --- a/packages/api/src/routes/accounts.ts +++ b/packages/api/src/routes/accounts.ts @@ -26,11 +26,13 @@ const BUILTIN_CLIENT_FOR_ID: Record = { claude: 'anthropic', codex: 'openai', gemini: 'google', + kimi: 'kimi', dare: 'dare', opencode: 'opencode', builtin_anthropic: 'anthropic', builtin_openai: 'openai', builtin_google: 'google', + builtin_kimi: 'kimi', builtin_dare: 'dare', builtin_opencode: 'opencode', }; diff --git a/packages/api/src/routes/capabilities.ts b/packages/api/src/routes/capabilities.ts index 42cc60777..f549af169 100644 --- a/packages/api/src/routes/capabilities.ts +++ b/packages/api/src/routes/capabilities.ts @@ -204,6 +204,7 @@ function getDiscoveryPaths(projectRoot: string) { claudeConfig: join(projectRoot, '.mcp.json'), codexConfig: join(projectRoot, '.codex', 'config.toml'), geminiConfig: join(projectRoot, '.gemini', 'settings.json'), + kimiConfig: join(projectRoot, '.kimi', 'mcp.json'), }; } @@ -212,6 +213,7 @@ function getCliConfigPaths(projectRoot: string) { anthropic: join(projectRoot, '.mcp.json'), openai: join(projectRoot, '.codex', 'config.toml'), google: join(projectRoot, '.gemini', 'settings.json'), + kimi: join(projectRoot, '.kimi', 'mcp.json'), }; } @@ -220,6 +222,44 @@ interface SkillMeta { triggers?: string[]; } +interface SkillScanPlan { + key: string; + provider: 'anthropic' | 'openai' | 'google' | 'kimi'; + path: string; + exclude?: string[]; +} + +export async function scanProviderSkillDirs(plans: SkillScanPlan[]): Promise<{ + providerSkills: Record; + scanResults: Record; + scansOk: boolean; +}> { + const providerSkills: Record = {}; + const scanResults: Record = {}; + + for (const plan of plans) { + if (!providerSkills[plan.provider]) providerSkills[plan.provider] = []; + } + + const results = await Promise.all( + plans.map(async (plan) => { + const names = await listSkillSubdirs(plan.path, plan.exclude); + return { plan, names }; + }), + ); + + let scansOk = true; + for (const { plan, names } of results) { + scanResults[plan.key] = names; + if (names === null) { + scansOk = false; + continue; + } + providerSkills[plan.provider] = [...new Set([...(providerSkills[plan.provider] ?? []), ...names])]; + } + + return { providerSkills, scanResults, scansOk }; +} /** * Extract description + triggers from a SKILL.md frontmatter. * Triggers are embedded in descriptions: @@ -469,19 +509,29 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { // placeholders for Gemini MCP) are applied to existing environments // without requiring a full re-bootstrap. writeXxxMcpConfig functions // are idempotent merge-writers, so repeated calls are safe and cheap. - await generateCliConfigs(config, getCliConfigPaths(projectRoot)); + try { + await generateCliConfigs(config, getCliConfigPaths(projectRoot)); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== 'EPERM' && code !== 'EACCES') throw error; + } // 2. Discover skills (filesystem scan — separate from MCP) // null = scan failed (readdir/read error); [] = directory exists but empty. // Use listSkillSubdirs() for provider dirs so stale/broken symlinks do not // resurrect deleted skills in the board. const projectSkillsDir = join(projectRoot, '.claude', 'skills'); - const [claudeProjectSkills, claudeUserSkills, codexSkills, geminiSkills] = await Promise.all([ - listSkillSubdirs(projectSkillsDir), - listSkillSubdirs(join(home, '.claude', 'skills')), - listSkillSubdirs(join(home, '.codex', 'skills'), ['.system']), - listSkillSubdirs(join(home, '.gemini', 'skills')), - ]); + const skillScanPlans: SkillScanPlan[] = [ + { key: 'claude-project', provider: 'anthropic', path: projectSkillsDir }, + { key: 'claude-user', provider: 'anthropic', path: join(home, '.claude', 'skills') }, + { key: 'codex-user', provider: 'openai', path: join(home, '.codex', 'skills'), exclude: ['.system'] }, + { key: 'gemini-user', provider: 'google', path: join(home, '.gemini', 'skills') }, + { key: 'kimi-project', provider: 'kimi', path: join(projectRoot, '.kimi', 'skills') }, + { key: 'kimi-user', provider: 'kimi', path: join(home, '.kimi', 'skills') }, + ]; + const { providerSkills, scanResults, scansOk: allScansOk } = await scanProviderSkillDirs(skillScanPlans); + const claudeProjectSkills = scanResults['claude-project']; + const projectKimiSkills = scanResults['kimi-project']; // F041 bug fix: Also scan cat-cafe-skills/ for project-level skill detection. // User-level skills (e.g. ~/.claude/skills/feat-completion) are symlinks to @@ -491,18 +541,11 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { const catCafeOwnSkills = await listSkillSubdirs(catCafeSkillsDir); const hasProjectCatCafeSkillsDir = existsSync(catCafeSkillsDir); - const allScansOk = - claudeProjectSkills !== null && claudeUserSkills !== null && codexSkills !== null && geminiSkills !== null; - - // F041 re-open: Track project-level skills for source classification - // Includes both .claude/skills/ AND cat-cafe-skills/ entries - const projectSkillNames = new Set([...(claudeProjectSkills ?? []), ...(catCafeOwnSkills ?? [])]); - - const providerSkills: Record = { - anthropic: [...new Set([...(claudeProjectSkills ?? []), ...(claudeUserSkills ?? [])])], - openai: codexSkills ?? [], - google: geminiSkills ?? [], - }; + const projectSkillNames = new Set([ + ...(claudeProjectSkills ?? []), + ...(projectKimiSkills ?? []), + ...(catCafeOwnSkills ?? []), + ]); // 3. Sync discovered skills into capabilities.json const allSkillNames = new Set(); @@ -543,7 +586,8 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { !shouldBeCatCafe && cap.source === 'cat-cafe' && catCafeOwnSkills !== null && - claudeProjectSkills !== null + claudeProjectSkills !== null && + projectKimiSkills !== null ) { cap.source = 'external'; configDirty = true; @@ -564,6 +608,7 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { claudeConfig: join(home, '.claude', 'mcp.json'), codexConfig: join(home, '.codex', 'config.toml'), geminiConfig: join(home, '.gemini', 'settings.json'), + kimiConfig: join(home, '.kimi', 'mcp.json'), }; const [projectLevelServers, userLevelServers] = await Promise.all([ discoverExternalMcpServers(projectLevelPaths), @@ -605,6 +650,8 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { skillDirCandidates.push({ name, dir: join(home, '.claude', 'skills', name) }); skillDirCandidates.push({ name, dir: join(home, '.codex', 'skills', name) }); skillDirCandidates.push({ name, dir: join(home, '.gemini', 'skills', name) }); + skillDirCandidates.push({ name, dir: join(home, '.kimi', 'skills', name) }); + skillDirCandidates.push({ name, dir: join(projectRoot, '.kimi', 'skills', name) }); } const metaResults = await Promise.all( @@ -725,16 +772,18 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { claude: join(home, '.claude', 'skills'), codex: join(home, '.codex', 'skills'), gemini: join(home, '.gemini', 'skills'), + kimi: join(home, '.kimi', 'skills'), }; await Promise.all( catCafeSkillItems.map(async (item) => { const expectedTarget = join(mountSkillsSrc, item.id); - const [claude, codex, gemini] = await Promise.all([ + const [claude, codex, gemini, kimi] = await Promise.all([ isCorrectSymlink(join(providerDirs.claude, item.id), expectedTarget, item.id, mainSkillsSrc), isCorrectSymlink(join(providerDirs.codex, item.id), expectedTarget, item.id, mainSkillsSrc), isCorrectSymlink(join(providerDirs.gemini, item.id), expectedTarget, item.id, mainSkillsSrc), + isCorrectSymlink(join(providerDirs.kimi, item.id), expectedTarget, item.id, mainSkillsSrc), ]); - item.mounts = { claude, codex, gemini }; + item.mounts = { claude, codex, gemini, kimi }; }), ); diff --git a/packages/api/src/routes/cats.ts b/packages/api/src/routes/cats.ts index 73fa7937a..5ca4e42fd 100644 --- a/packages/api/src/routes/cats.ts +++ b/packages/api/src/routes/cats.ts @@ -56,7 +56,7 @@ const cliSchema = z.object({ effort: cliEffortSchema.nullable().optional(), }); -const clientSchema = z.enum(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode']); +const clientSchema = z.enum(['anthropic', 'openai', 'google', 'kimi', 'dare', 'antigravity', 'opencode']); const catIdSchema = z .string() .min(1) @@ -190,6 +190,8 @@ function defaultCliForClient(client: ClientId): { command: string; outputFormat: return { command: 'codex', outputFormat: 'json' }; case 'google': return { command: 'gemini', outputFormat: 'stream-json' }; + case 'kimi': + return { command: 'kimi', outputFormat: 'stream-json' }; case 'dare': return { command: 'dare', outputFormat: 'json' }; case 'opencode': diff --git a/packages/api/src/routes/quota.ts b/packages/api/src/routes/quota.ts index c8da27c04..35859f908 100644 --- a/packages/api/src/routes/quota.ts +++ b/packages/api/src/routes/quota.ts @@ -5,7 +5,8 @@ * 1. Claude: Anthropic OAuth API(/api/oauth/usage)+ ccusage CLI fallback * 2. Codex: OpenAI Wham API(/backend-api/wham/usage)+ PATCH 推送 fallback * 3. Gemini: Google internal API + PATCH 推送 fallback - * 4. Antigravity: 本地 Language Server RPC + PATCH 推送 fallback + * 4. Kimi: CLI `/usage` 默认探测 + env-gated API fallback + * 5. Antigravity: 本地 Language Server RPC + PATCH 推送 fallback * * 硬约束:看板值 = 官方 API 值,不二次换算。获取失败显示"获取失败"。 */ @@ -16,7 +17,9 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import type { FastifyInstance } from 'fastify'; +import * as pty from 'node-pty'; import { z } from 'zod'; +import { resolveCliCommand } from '../utils/cli-resolve.js'; const execFileAsync = promisify(execFile); @@ -73,6 +76,15 @@ export interface GeminiQuota { lastChecked: string | null; } +export interface KimiQuota { + platform: 'kimi'; + usageItems: CodexUsageItem[]; + error?: string; + lastChecked: string | null; + status?: 'ok' | 'unavailable'; + note?: string; +} + export interface AntigravityQuota { platform: 'antigravity'; usageItems: CodexUsageItem[]; @@ -84,11 +96,12 @@ export interface QuotaResponse { claude: ClaudeQuota; codex: CodexQuota; gemini: GeminiQuota; + kimi: KimiQuota; antigravity: AntigravityQuota; fetchedAt: string; } -export type QuotaProbeTargetPlatform = 'claude' | 'codex' | 'antigravity'; +export type QuotaProbeTargetPlatform = 'claude' | 'codex' | 'kimi' | 'antigravity'; export type QuotaProbeRuntimeStatus = 'ok' | 'error' | 'disabled'; export interface QuotaProbeAction { @@ -99,7 +112,7 @@ export interface QuotaProbeAction { } export interface QuotaProbeDescriptor { - id: 'claude-cli' | 'official-browser' | 'antigravity-placeholder'; + id: 'claude-cli' | 'official-browser' | 'kimi-cli' | 'antigravity-placeholder'; sourceKind: 'cli' | 'browser' | 'placeholder'; refreshMode: 'manual' | 'scheduled'; enabled: boolean; @@ -132,15 +145,18 @@ export interface QuotaSummaryResponse { platforms: { codex: QuotaSummaryPlatform; claude: QuotaSummaryPlatform; + kimi: QuotaSummaryPlatform; antigravity: QuotaSummaryPlatform; }; probes: { official: Pick; claudeCli: Pick; + kimi: Pick; }; actions: { refreshOfficialPath: '/api/quota/refresh/official'; refreshClaudePath: '/api/quota/refresh/claude'; + refreshKimiPath: '/api/quota/refresh/kimi'; }; } @@ -171,6 +187,16 @@ function createInitialGeminiCache(): GeminiQuota { }; } +function createInitialKimiCache(): KimiQuota { + return { + platform: 'kimi', + usageItems: [], + lastChecked: null, + status: 'unavailable', + note: '暂无 Kimi CLI 额度数据,请先手动刷新。', + }; +} + function createInitialAntigravityCache(): AntigravityQuota { return { platform: 'antigravity', @@ -182,18 +208,32 @@ function createInitialAntigravityCache(): AntigravityQuota { let claudeCache: ClaudeQuota = createInitialClaudeCache(); let codexCache: CodexQuota = createInitialCodexCache(); let geminiCache: GeminiQuota = createInitialGeminiCache(); +let kimiCache: KimiQuota = createInitialKimiCache(); let antigravityCache: AntigravityQuota = createInitialAntigravityCache(); +let kimiCliProbeOverrideForTests: ((env?: NodeJS.ProcessEnv) => Promise) | null = null; export function resetQuotaCachesForTests(): void { claudeCache = createInitialClaudeCache(); codexCache = createInitialCodexCache(); geminiCache = createInitialGeminiCache(); + kimiCache = createInitialKimiCache(); antigravityCache = createInitialAntigravityCache(); + kimiCliProbeOverrideForTests = null; +} + +export function setKimiCliProbeOverrideForTests( + override: ((env?: NodeJS.ProcessEnv) => Promise) | null, +): void { + kimiCliProbeOverrideForTests = override; } const OFFICIAL_REFRESH_ENABLED_ENV = 'QUOTA_OFFICIAL_REFRESH_ENABLED'; const CLAUDE_CREDENTIALS_PATH_ENV = 'CLAUDE_CREDENTIALS_PATH'; const CODEX_CREDENTIALS_PATH_ENV = 'CODEX_CREDENTIALS_PATH'; +const KIMI_AUTH_TOKEN_ENV = 'KIMI_AUTH_TOKEN'; +const KIMI_QUOTA_API_FALLBACK_ENABLED_ENV = 'KIMI_QUOTA_API_FALLBACK_ENABLED'; +const KIMI_CLI_PROBE_TIMEOUT_MS = 15_000; +const KIMI_CLI_IDLE_SETTLE_MS = 350; function isTruthyFlag(raw: string | undefined): boolean { if (!raw) return false; @@ -208,6 +248,21 @@ function hasOfficialProbeFailure(): boolean { }); } +function isKimiQuotaApiFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return isTruthyFlag(env[KIMI_QUOTA_API_FALLBACK_ENABLED_ENV]); +} + +function isKimiCliProbeAvailable(): boolean { + return kimiCliProbeOverrideForTests != null || Boolean(resolveCliCommand('kimi')); +} + +function getKimiProbeStatus(env: NodeJS.ProcessEnv = process.env): QuotaProbeRuntimeStatus { + const fallbackConfigured = isKimiQuotaApiFallbackEnabled(env) && Boolean(resolveKimiAuthToken(env)); + if (!isKimiCliProbeAvailable() && !fallbackConfigured) return 'disabled'; + if (kimiCache.error) return 'error'; + return kimiCache.status === 'ok' ? 'ok' : 'error'; +} + export function listQuotaProbeDescriptors(env: NodeJS.ProcessEnv = process.env): QuotaProbeDescriptor[] { const officialRefreshEnabled = isTruthyFlag(env[OFFICIAL_REFRESH_ENABLED_ENV]); const officialStatus: QuotaProbeRuntimeStatus = !officialRefreshEnabled @@ -216,6 +271,7 @@ export function listQuotaProbeDescriptors(env: NodeJS.ProcessEnv = process.env): ? 'error' : 'ok'; const claudeStatus: QuotaProbeRuntimeStatus = /ccusage failed/i.test(claudeCache.error ?? '') ? 'error' : 'ok'; + const kimiStatus = getKimiProbeStatus(env); return [ { @@ -258,7 +314,31 @@ export function listQuotaProbeDescriptors(env: NodeJS.ProcessEnv = process.env): ? 'Disabled by default for risk control. Set QUOTA_OFFICIAL_REFRESH_ENABLED=1 to enable.' : officialStatus === 'error' ? (codexCache.error ?? claudeCache.error ?? 'official OAuth probe error') - : 'Enabled. Uses Anthropic/OpenAI OAuth APIs (ClaudeBar-compatible).', + : 'Enabled. Uses Claude/Codex OAuth APIs.', + }, + { + id: 'kimi-cli', + sourceKind: 'cli', + refreshMode: 'manual', + enabled: kimiStatus !== 'disabled', + status: kimiStatus, + targets: ['kimi'], + actions: [ + { + kind: 'refresh', + method: 'POST', + path: '/api/quota/refresh/kimi', + requiresInteractive: false, + }, + ], + reason: + kimiStatus === 'disabled' + ? `Kimi CLI not found. Install kimi to use /usage by default, or set ${KIMI_QUOTA_API_FALLBACK_ENABLED_ENV}=1 with ${KIMI_AUTH_TOKEN_ENV} to allow API fallback.` + : (kimiCache.error ?? + kimiCache.note ?? + (isKimiQuotaApiFallbackEnabled(env) + ? `Enabled. Uses Kimi CLI /usage by default; API fallback is allowed when ${KIMI_AUTH_TOKEN_ENV} is available.` + : 'Enabled. Uses Kimi CLI /usage by default.')), }, { id: 'antigravity-placeholder', @@ -273,6 +353,228 @@ export function listQuotaProbeDescriptors(env: NodeJS.ProcessEnv = process.env): ]; } +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ''); +} + +export function parseKimiCliUsageOutput(text: string): CodexUsageItem[] { + const cleaned = stripAnsi(text); + const items: CodexUsageItem[] = []; + for (const line of cleaned.split(/\r?\n/)) { + const lower = line.toLowerCase(); + const percentMatch = line.match(/(\d+)%\s+left/i); + if (!percentMatch) continue; + const remaining = normalizePercent(Number.parseInt(percentMatch[1] ?? '', 10)); + const resetMatch = line.match(/\(resets\s+in\s+(.+?)\)/i); + if (lower.includes('weekly')) { + items.push({ + label: '每周使用限额', + usedPercent: remaining, + percentKind: 'remaining', + poolId: 'kimi-weekly', + ...(resetMatch?.[1] ? { resetsText: `Resets in ${resetMatch[1].trim()}` } : {}), + }); + continue; + } + if (lower.includes('5h') || lower.includes('5 hour') || lower.includes('5-hour')) { + items.push({ + label: '5小时使用限额', + usedPercent: remaining, + percentKind: 'remaining', + poolId: 'kimi-rate-limit', + ...(resetMatch?.[1] ? { resetsText: `Resets in ${resetMatch[1].trim()}` } : {}), + }); + } + } + return items; +} + +const KIMI_BILLING_URL = 'https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages'; + +interface KimiUsageResponse { + usages: Array<{ + scope: string; + detail: { + limit: string; + used?: string | null; + remaining?: string | null; + resetTime?: string | null; + }; + limits?: Array<{ + window?: { + duration?: number | null; + timeUnit?: string | null; + } | null; + detail: { + limit: string; + used?: string | null; + remaining?: string | null; + resetTime?: string | null; + }; + }> | null; + }>; +} + +function resolveKimiAuthToken(env: NodeJS.ProcessEnv = process.env): string | null { + const raw = env[KIMI_AUTH_TOKEN_ENV]?.trim(); + if (raw) return raw; + return null; +} + +async function probeKimiQuotaViaCli(env: NodeJS.ProcessEnv = process.env): Promise { + if (kimiCliProbeOverrideForTests) return kimiCliProbeOverrideForTests(env); + + const kimiCommand = resolveCliCommand('kimi'); + if (!kimiCommand) throw new Error('Kimi CLI not found in PATH'); + + return await new Promise((resolve, reject) => { + let settled = false; + let sentUsage = false; + let output = ''; + + const proc = pty.spawn(kimiCommand, [], { + name: 'xterm-color', + cols: 120, + rows: 40, + cwd: process.cwd(), + env: { ...process.env, ...env }, + }); + + const finish = (value: CodexUsageItem[] | null, error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(startTimer); + clearTimeout(idleTimer); + clearTimeout(timeoutTimer); + try { + proc.kill(); + } catch { + // best effort + } + if (error) reject(error); + else resolve(value ?? []); + }; + + const tryParse = (): boolean => { + const items = parseKimiCliUsageOutput(output); + if (items.length > 0) { + finish(items); + return true; + } + return false; + }; + + const sendUsage = () => { + if (settled || sentUsage) return; + sentUsage = true; + try { + proc.write('/usage\r'); + } catch (error) { + finish(null, error instanceof Error ? error : new Error(String(error))); + } + }; + + const startTimer = setTimeout(sendUsage, 500); + let idleTimer = setTimeout(() => { + if (!tryParse()) { + finish(null, new Error('Kimi CLI /usage output did not contain quota data')); + } + }, KIMI_CLI_IDLE_SETTLE_MS); + const timeoutTimer = setTimeout(() => { + finish(null, new Error(`Kimi CLI quota probe timed out after ${Math.round(KIMI_CLI_PROBE_TIMEOUT_MS / 1000)}s`)); + }, KIMI_CLI_PROBE_TIMEOUT_MS); + + proc.onData((chunk) => { + output += chunk; + if (!sentUsage && /💫|weekly limit|5h limit|api usage/i.test(output)) { + sendUsage(); + } + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (!tryParse()) { + finish(null, new Error('Kimi CLI /usage output did not contain quota data')); + } + }, KIMI_CLI_IDLE_SETTLE_MS); + }); + + proc.onExit(() => { + if (!tryParse()) { + finish(null, new Error('Kimi CLI exited before quota data was parsed')); + } + }); + }); +} + +function decodeKimiTokenContext(token: string): { deviceId?: string; sessionId?: string; trafficId?: string } | null { + try { + const parts = token.split('.'); + if (parts.length < 2) return null; + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (payload.length % 4 !== 0) payload += '='; + const decoded = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as Record; + return { + deviceId: typeof decoded.device_id === 'string' ? decoded.device_id : undefined, + sessionId: typeof decoded.ssid === 'string' ? decoded.ssid : undefined, + trafficId: typeof decoded.sub === 'string' ? decoded.sub : undefined, + }; + } catch { + return null; + } +} + +function parseKimiUsageValue(detail: { limit: string; used?: string | null; remaining?: string | null }): { + limit: number; + used: number; + remaining: number | null; +} | null { + const limit = Number.parseInt(detail.limit, 10); + if (!Number.isFinite(limit) || limit <= 0) return null; + const remaining = detail.remaining != null ? Number.parseInt(detail.remaining, 10) : null; + const used = + detail.used != null + ? Number.parseInt(detail.used, 10) + : Number.isFinite(remaining as number) + ? Math.max(0, limit - (remaining as number)) + : 0; + return { + limit, + used: Number.isFinite(used) ? used : 0, + remaining: Number.isFinite(remaining as number) ? (remaining as number) : null, + }; +} + +export function parseKimiOfficialUsageResponse(json: KimiUsageResponse): CodexUsageItem[] { + const codingUsage = Array.isArray(json.usages) ? json.usages.find((item) => item.scope === 'FEATURE_CODING') : null; + if (!codingUsage) return []; + const items: CodexUsageItem[] = []; + const weekly = parseKimiUsageValue(codingUsage.detail); + if (weekly) { + items.push({ + label: '每周使用限额', + usedPercent: normalizePercent(Math.round((weekly.used / weekly.limit) * 10000) / 100), + percentKind: 'used', + poolId: 'kimi-weekly', + ...(codingUsage.detail.resetTime ? { resetsAt: codingUsage.detail.resetTime } : {}), + resetsText: `${weekly.used}/${weekly.limit} requests`, + }); + } + const rateLimit = Array.isArray(codingUsage.limits) + ? codingUsage.limits.find((item) => item?.window?.duration === 5 && /hour/i.test(item?.window?.timeUnit ?? '')) + : null; + const rate = rateLimit ? parseKimiUsageValue(rateLimit.detail) : null; + if (rate) { + items.push({ + label: '5小时使用限额', + usedPercent: normalizePercent(Math.round((rate.used / rate.limit) * 10000) / 100), + percentKind: 'used', + poolId: 'kimi-rate-limit', + ...(rateLimit?.detail.resetTime ? { resetsAt: rateLimit.detail.resetTime } : {}), + resetsText: `${rate.used}/${rate.limit} requests / 5h`, + }); + } + return items; +} + function normalizePercent(value: number): number { return Math.max(0, Math.min(100, value)); } @@ -433,17 +735,76 @@ function buildAntigravitySummaryPlatform(): QuotaSummaryPlatform { }; } +function buildKimiSummaryPlatform(): QuotaSummaryPlatform { + if (kimiCache.error) { + return { + id: 'kimi', + label: '梵花猫 (Kimi)', + displayPercent: null, + displayKind: null, + utilizationPercent: null, + status: 'error', + note: kimiCache.error, + lastChecked: kimiCache.lastChecked, + }; + } + if (kimiCache.status === 'unavailable') { + return { + id: 'kimi', + label: '梵花猫 (Kimi)', + displayPercent: null, + displayKind: null, + utilizationPercent: null, + status: 'pending', + note: + kimiCache.note ?? + (isKimiQuotaApiFallbackEnabled(process.env) + ? `暂无 Kimi CLI 额度数据;若 CLI 失败可按配置降级到 API。` + : '暂无 Kimi CLI 额度数据,请点击刷新。'), + lastChecked: kimiCache.lastChecked, + }; + } + const primary = pickPrimaryUsageItem(kimiCache.usageItems); + if (!primary) { + return { + id: 'kimi', + label: '梵花猫 (Kimi)', + displayPercent: null, + displayKind: null, + utilizationPercent: null, + status: 'pending', + note: '暂无 Kimi 额度数据。', + lastChecked: kimiCache.lastChecked, + }; + } + const utilization = toUtilizationPercent(primary); + return { + id: 'kimi', + label: '梵花猫 (Kimi)', + displayPercent: normalizePercent(primary.usedPercent), + displayKind: primary.percentKind ?? 'used', + utilizationPercent: utilization, + status: statusFromUtilization(utilization), + note: primary.resetsText ?? primary.resetsAt ?? primary.label, + lastChecked: kimiCache.lastChecked, + }; +} + export function buildQuotaSummary(env: NodeJS.ProcessEnv = process.env): QuotaSummaryResponse { const probes = listQuotaProbeDescriptors(env); const officialProbe = probes.find((probe) => probe.id === 'official-browser'); const claudeCliProbe = probes.find((probe) => probe.id === 'claude-cli'); const codex = buildCodexSummaryPlatform(); const claude = buildClaudeSummaryPlatform(); + const kimi = buildKimiSummaryPlatform(); const antigravity = buildAntigravitySummaryPlatform(); - const utilizationValues = [codex.utilizationPercent, claude.utilizationPercent].filter( - (value): value is number => typeof value === 'number' && Number.isFinite(value), - ); + const utilizationValues = [ + codex.utilizationPercent, + claude.utilizationPercent, + kimi.utilizationPercent, + antigravity.utilizationPercent, + ].filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); const maxUtilization = utilizationValues.length > 0 ? Math.max(...utilizationValues) : null; const reasons: string[] = []; @@ -469,6 +830,11 @@ export function buildQuotaSummary(env: NodeJS.ProcessEnv = process.env): QuotaSu level = 'high'; } + if (kimi.status === 'error') { + reasons.push(`梵花猫额度异常:${kimi.note}`); + level = 'high'; + } + if (maxUtilization != null && maxUtilization >= 95) { reasons.push(`综合利用率达到 ${maxUtilization}%(高风险)`); level = 'high'; @@ -487,6 +853,7 @@ export function buildQuotaSummary(env: NodeJS.ProcessEnv = process.env): QuotaSu platforms: { codex, claude, + kimi, antigravity, }, probes: { @@ -500,10 +867,20 @@ export function buildQuotaSummary(env: NodeJS.ProcessEnv = process.env): QuotaSu status: claudeCliProbe?.status ?? 'ok', reason: claudeCliProbe?.reason ?? 'claude-cli probe unavailable', }, + kimi: { + enabled: probes.some((probe) => probe.id === 'kimi-cli' && probe.enabled), + status: probes.find((probe) => probe.id === 'kimi-cli')?.status ?? getKimiProbeStatus(env), + reason: + probes.find((probe) => probe.id === 'kimi-cli')?.reason ?? + kimiCache.error ?? + kimiCache.note ?? + 'Kimi CLI probe unavailable', + }, }, actions: { refreshOfficialPath: '/api/quota/refresh/official', refreshClaudePath: '/api/quota/refresh/claude', + refreshKimiPath: '/api/quota/refresh/kimi', }, }; } @@ -666,6 +1043,7 @@ interface CodexOAuthCredentials extends OAuthCredentials { interface RefreshOAuthOptions { claudeCredentials: OAuthCredentials | null; codexCredentials: CodexOAuthCredentials | null; + kimiAuthToken?: string | null; fetchLike?: typeof globalThis.fetch; } @@ -677,6 +1055,7 @@ interface RefreshOAuthProviderResult { interface RefreshOAuthResult { claude?: RefreshOAuthProviderResult; codex?: RefreshOAuthProviderResult; + kimi?: RefreshOAuthProviderResult; skipped?: string[]; } @@ -846,11 +1225,141 @@ export async function refreshOfficialQuotaViaOAuth(options: RefreshOAuthOptions) skipped.push('codex'); } + if (options.kimiAuthToken) { + tasks.push( + (async () => { + const token = options.kimiAuthToken!; + const tokenContext = decodeKimiTokenContext(token); + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + Cookie: `kimi-auth=${token}`, + Origin: 'https://www.kimi.com', + Referer: 'https://www.kimi.com/code/console', + Accept: '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36', + 'connect-protocol-version': '1', + 'x-language': 'en-US', + 'x-msh-platform': 'web', + }; + if (tokenContext?.deviceId) headers['x-msh-device-id'] = tokenContext.deviceId; + if (tokenContext?.sessionId) headers['x-msh-session-id'] = tokenContext.sessionId; + if (tokenContext?.trafficId) headers['x-traffic-id'] = tokenContext.trafficId; + try { + const response = await fetchFn(KIMI_BILLING_URL, { + method: 'POST', + headers, + body: JSON.stringify({ scope: ['FEATURE_CODING'] }), + }); + if (response.status === 401 || response.status === 403) { + throw new Error(`Kimi auth failed: HTTP ${response.status}`); + } + if (!response.ok) { + throw new Error(`Kimi billing API failed: HTTP ${response.status}`); + } + const json = (await response.json()) as KimiUsageResponse; + const items = parseKimiOfficialUsageResponse(json); + if (items.length === 0) { + throw new Error('Kimi billing API returned no FEATURE_CODING usage windows'); + } + kimiCache = { + platform: 'kimi', + usageItems: items, + lastChecked: new Date().toISOString(), + status: 'ok', + note: '来自 Kimi 官方额度接口(每周 + 5 小时窗口)。', + }; + result.kimi = { items: items.length }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + kimiCache = { + platform: 'kimi', + usageItems: [], + error: message, + lastChecked: new Date().toISOString(), + status: 'unavailable', + note: message, + }; + result.kimi = { items: 0, error: message }; + } + })(), + ); + } else { + skipped.push('kimi'); + } + await Promise.all(tasks); if (skipped.length > 0) result.skipped = skipped; return result; } +export async function refreshKimiQuota(options?: { + env?: NodeJS.ProcessEnv; + fetchLike?: typeof globalThis.fetch; +}): Promise<{ source: 'cli' | 'api'; items: number; fallbackUsed: boolean; error?: string }> { + const env = options?.env ?? process.env; + const checkedAt = new Date().toISOString(); + try { + const items = await probeKimiQuotaViaCli(env); + kimiCache = { + platform: 'kimi', + usageItems: items, + lastChecked: checkedAt, + status: 'ok', + note: '来自 Kimi CLI /usage。', + }; + return { source: 'cli', items: items.length, fallbackUsed: false }; + } catch (cliError) { + const cliMessage = cliError instanceof Error ? cliError.message : String(cliError); + const fallbackEnabled = isKimiQuotaApiFallbackEnabled(env); + const kimiAuthToken = resolveKimiAuthToken(env); + if (fallbackEnabled && kimiAuthToken) { + const apiResult = await refreshOfficialQuotaViaOAuth({ + claudeCredentials: null, + codexCredentials: null, + kimiAuthToken, + fetchLike: options?.fetchLike, + }); + if ((apiResult.kimi?.items ?? 0) > 0 && !apiResult.kimi?.error) { + kimiCache = { + ...kimiCache, + error: undefined, + lastChecked: checkedAt, + note: 'Kimi CLI /usage 失败,已按配置降级到 Kimi API。', + }; + return { source: 'api', items: apiResult.kimi?.items ?? 0, fallbackUsed: true }; + } + const apiMessage = apiResult.kimi?.error ?? `Kimi API fallback failed after CLI error: ${cliMessage}`; + const message = `Kimi CLI /usage failed: ${cliMessage}; API fallback failed: ${apiMessage}`; + kimiCache = { + platform: 'kimi', + usageItems: [], + error: message, + lastChecked: checkedAt, + status: 'unavailable', + note: message, + }; + return { source: 'api', items: 0, fallbackUsed: true, error: message }; + } + + const fallbackHint = fallbackEnabled + ? `API fallback is enabled but ${KIMI_AUTH_TOKEN_ENV} is missing.` + : `API fallback is disabled. Set ${KIMI_QUOTA_API_FALLBACK_ENABLED_ENV}=1 and ${KIMI_AUTH_TOKEN_ENV} to allow fallback.`; + const message = `Kimi CLI /usage failed: ${cliMessage}. ${fallbackHint}`; + kimiCache = { + platform: 'kimi', + usageItems: [], + error: message, + lastChecked: checkedAt, + status: 'unavailable', + note: message, + }; + return { source: 'cli', items: 0, fallbackUsed: false, error: message }; + } +} + // --- Route --- export async function quotaRoutes(app: FastifyInstance): Promise { @@ -867,6 +1376,7 @@ export async function quotaRoutes(app: FastifyInstance): Promise { claude: claudeCache, codex: codexCache, gemini: geminiCache, + kimi: kimiCache, antigravity: antigravityCache, fetchedAt: new Date().toISOString(), }; @@ -878,6 +1388,15 @@ export async function quotaRoutes(app: FastifyInstance): Promise { return buildQuotaSummary(); }); + // POST: refresh Kimi quota (CLI by default, API fallback only when explicitly enabled) + app.post('/api/quota/refresh/kimi', async (_request, reply) => { + const result = await refreshKimiQuota(); + if (result.error) { + return reply.status(502).send({ error: result.error }); + } + return { kimi: kimiCache, source: result.source, fallbackUsed: result.fallbackUsed }; + }); + // POST: refresh Claude quota via ccusage CLI app.post('/api/quota/refresh/claude', async () => { try { @@ -921,10 +1440,8 @@ export async function quotaRoutes(app: FastifyInstance): Promise { // Load credentials from files const claudeCredentials = loadClaudeCredentials(process.env[CLAUDE_CREDENTIALS_PATH_ENV]); const codexCredentials = loadCodexCredentials(process.env[CODEX_CREDENTIALS_PATH_ENV]); - if (!claudeCredentials && !codexCredentials) { - const message = - 'No OAuth credentials found. Claude: ~/.claude/.credentials.json, Codex: set CODEX_CREDENTIALS_PATH.'; + const message = `No official quota credentials found. Claude: ~/.claude/.credentials.json, Codex: set ${CODEX_CREDENTIALS_PATH_ENV}.`; const checkedAt = new Date().toISOString(); codexCache = { ...codexCache, error: message, lastChecked: checkedAt }; claudeCache = { ...claudeCache, error: message, lastChecked: checkedAt }; diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts index 99662a154..8c3a64718 100644 --- a/packages/api/src/routes/skills.ts +++ b/packages/api/src/routes/skills.ts @@ -2,7 +2,7 @@ * Skills Route * GET /api/skills — Clowder AI 共享 Skills 看板数据 * - * 扫描 cat-cafe-skills/ 源目录,检查三猫 symlink 挂载状态, + * 扫描 cat-cafe-skills/ 源目录,检查 Claude/Codex/Gemini/Kimi provider symlink 挂载状态, * 解析 BOOTSTRAP.md 提取分类,解析 manifest.yaml 提取触发词。 */ @@ -21,6 +21,7 @@ interface SkillMount { claude: boolean; codex: boolean; gemini: boolean; + kimi: boolean; } interface SkillEntry { @@ -214,6 +215,7 @@ export const skillsRoutes: FastifyPluginAsync = async (app) => { claude: join(home, '.claude', 'skills'), codex: join(home, '.codex', 'skills'), gemini: join(home, '.gemini', 'skills'), + kimi: join(home, '.kimi', 'skills'), }; const [sourceSkills, bootstrapEntries, manifestMeta] = await Promise.all([ @@ -229,10 +231,11 @@ export const skillsRoutes: FastifyPluginAsync = async (app) => { await Promise.all( sourceSkills.map(async (name) => { const expectedTarget = join(skillsSrc, name); - const [claude, codex, gemini] = await Promise.all([ + const [claude, codex, gemini, kimi] = await Promise.all([ isCorrectSymlink(join(catDirs.claude, name), expectedTarget), isCorrectSymlink(join(catDirs.codex, name), expectedTarget), isCorrectSymlink(join(catDirs.gemini, name), expectedTarget), + isCorrectSymlink(join(catDirs.kimi, name), expectedTarget), ]); const entry = bootstrapEntries.get(name); const meta = manifestMeta.get(name); @@ -241,7 +244,7 @@ export const skillsRoutes: FastifyPluginAsync = async (app) => { name, category: entry?.category ?? '未分类', trigger, - mounts: { claude, codex, gemini }, + mounts: { claude, codex, gemini, kimi }, ...(meta?.requiresMcp?.length ? { requiresMcp: meta.requiresMcp.map((id) => mcpStatuses.get(id) ?? { id, status: 'missing' }), @@ -272,7 +275,7 @@ export const skillsRoutes: FastifyPluginAsync = async (app) => { const phantom = [...bootstrapNames].filter((n) => !sourceNames.has(n)); const registrationConsistent = unregistered.length === 0 && phantom.length === 0; - const allMounted = skills.every((s) => s.mounts.claude && s.mounts.codex && s.mounts.gemini); + const allMounted = skills.every((s) => s.mounts.claude && s.mounts.codex && s.mounts.gemini && s.mounts.kimi); const response: SkillsResponse = { skills, diff --git a/packages/api/src/routes/workspace.ts b/packages/api/src/routes/workspace.ts index 58835cf48..b987bbd64 100644 --- a/packages/api/src/routes/workspace.ts +++ b/packages/api/src/routes/workspace.ts @@ -249,7 +249,7 @@ async function buildTree(root: string, dirPath: string, depth: number, maxDepth: }); for (const entry of sorted) { - if (entry.name.startsWith('.') && entry.name !== '.claude') continue; + if (entry.name.startsWith('.') && entry.name !== '.claude' && entry.name !== '.kimi') continue; if (SKIP_DIRS.has(entry.name)) continue; const fullPath = join(dirPath, entry.name); diff --git a/packages/api/src/utils/cli-resolve.ts b/packages/api/src/utils/cli-resolve.ts index 1e03028dd..3dea7eff6 100644 --- a/packages/api/src/utils/cli-resolve.ts +++ b/packages/api/src/utils/cli-resolve.ts @@ -76,6 +76,7 @@ export function formatCliNotFoundError(command: string): string { claude: 'npm install -g @anthropic-ai/claude-code', codex: 'npm install -g @openai/codex', gemini: 'npm install -g @google/gemini-cli', + kimi: 'uv tool install --python 3.13 kimi-cli', opencode: 'npm install -g opencode', }; const hint = installHints[command] ?? `install the "${command}" CLI`; diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index 668ea0fef..badffaffa 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -74,6 +74,52 @@ describe('parseA2AMentions', () => { assert.deepEqual(result, ['opus', 'codex']); }); + it('routes multiple @mentions on the same line when the line is a pure handoff target list', async () => { + const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); + + const originalConfigs = catRegistry.getAllConfigs(); + catRegistry.reset(); + try { + const runtimeConfigs = toAllCatConfigs(loadCatConfig()); + for (const [id, config] of Object.entries(runtimeConfigs)) { + catRegistry.register(id, config); + } + + const text = '到我这里结束了吗?是的 — 我的编译修复已完成,等待 commit + push 和 CI 结果。\n@opus @gpt52'; + const result = parseA2AMentions(text, 'kimi'); + assert.deepEqual(result, ['opus', 'gpt52']); + } finally { + catRegistry.reset(); + for (const [id, config] of Object.entries(originalConfigs)) { + catRegistry.register(id, config); + } + } + }); + + it('does not treat later inline mentions as actionable once prose starts on the line', async () => { + const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); + + const originalConfigs = catRegistry.getAllConfigs(); + catRegistry.reset(); + try { + const runtimeConfigs = toAllCatConfigs(loadCatConfig()); + for (const [id, config] of Object.entries(runtimeConfigs)) { + catRegistry.register(id, config); + } + + const text = '@opus 请继续推进,如果需要再找 @gpt52'; + const result = parseA2AMentions(text, 'kimi'); + assert.deepEqual(result, ['opus']); + } finally { + catRegistry.reset(); + for (const [id, config] of Object.entries(originalConfigs)) { + catRegistry.register(id, config); + } + } + }); + // === Content-before-mention: 上文写内容,最后一行 @ (缅因猫习惯) === it('routes when content comes before @mention (content-before-mention pattern)', async () => { @@ -83,6 +129,34 @@ describe('parseA2AMentions', () => { assert.deepEqual(result, ['opus']); }); + it('routes line-start @mention after markdown numbered-list prefix', async () => { + const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '1. @codex 帮忙看下这个实现'; + const result = parseA2AMentions(text, 'kimi'); + assert.deepEqual(result, ['codex']); + }); + + it('routes line-start @mention after markdown bullet prefix', async () => { + const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '- @codex please review this patch'; + const result = parseA2AMentions(text, 'opus'); + assert.deepEqual(result, ['codex']); + }); + + it('routes line-start @mention after markdown quote prefix', async () => { + const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '> @gemini 看下这个视觉方案'; + const result = parseA2AMentions(text, 'codex'); + assert.deepEqual(result, ['gemini']); + }); + + it('matches markdown-style .md suffix handles emitted by Kimi', async () => { + const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '@KIMI.md 这个给你继续'; + const result = parseA2AMentions(text, 'opus'); + assert.deepEqual(result, ['kimi']); + }); + it('analyzeA2AMentions returns empty suppressed (no suppression system)', async () => { const { analyzeA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); const result = analyzeA2AMentions('@布偶猫', 'codex'); diff --git a/packages/api/test/bootcamp-env-check.test.js b/packages/api/test/bootcamp-env-check.test.js index 25e8c727d..fce4fa987 100644 --- a/packages/api/test/bootcamp-env-check.test.js +++ b/packages/api/test/bootcamp-env-check.test.js @@ -37,6 +37,9 @@ describe('F087: Bootcamp env-check route', () => { assert.ok('pnpm' in body, 'missing pnpm'); assert.ok('git' in body, 'missing git'); assert.ok('claudeCli' in body, 'missing claudeCli'); + assert.ok('codexCli' in body, 'missing codexCli'); + assert.ok('geminiCli' in body, 'missing geminiCli'); + assert.ok('kimiCli' in body, 'missing kimiCli'); assert.ok('mcp' in body, 'missing mcp'); // Advanced features assert.ok('tts' in body, 'missing tts'); @@ -47,6 +50,7 @@ describe('F087: Bootcamp env-check route', () => { assert.strictEqual(typeof body.node.ok, 'boolean'); assert.strictEqual(typeof body.pnpm.ok, 'boolean'); assert.strictEqual(typeof body.git.ok, 'boolean'); + assert.strictEqual(typeof body.kimiCli.ok, 'boolean'); // node/pnpm/git should be ok in dev environment assert.ok(body.node.ok, 'node should be available'); diff --git a/packages/api/test/callback-bootcamp-env-check.test.js b/packages/api/test/callback-bootcamp-env-check.test.js index 7faf35e8e..fd72642fa 100644 --- a/packages/api/test/callback-bootcamp-env-check.test.js +++ b/packages/api/test/callback-bootcamp-env-check.test.js @@ -84,6 +84,9 @@ describe('Callback Bootcamp Env Check', () => { assert.ok('pnpm' in body); assert.ok('git' in body); assert.ok('claudeCli' in body); + assert.ok('codexCli' in body); + assert.ok('geminiCli' in body); + assert.ok('kimiCli' in body); assert.ok('mcp' in body); assert.ok('tts' in body); assert.ok('asr' in body); diff --git a/packages/api/test/capabilities-route.test.js b/packages/api/test/capabilities-route.test.js index 2d1812a7a..267f03e56 100644 --- a/packages/api/test/capabilities-route.test.js +++ b/packages/api/test/capabilities-route.test.js @@ -7,7 +7,7 @@ */ import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; -import { mkdir, rm, symlink } from 'node:fs/promises'; +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; @@ -28,6 +28,54 @@ async function makeTmpDir(prefix) { // ────────── PATCH logic (unit-level, no Fastify needed) ────────── +describe('scanProviderSkillDirs', () => { + it('merges multi-source provider results deterministically', async () => { + const { scanProviderSkillDirs } = await import('../dist/routes/capabilities.js'); + const root = join(process.cwd(), '.test-tmp-cap-scan-' + Date.now()); + const kimiProject = join(root, '.kimi', 'skills'); + const kimiUser = join(root, 'home', '.kimi', 'skills'); + await mkdir(join(kimiProject, 'alpha'), { recursive: true }); + await mkdir(join(kimiUser, 'beta'), { recursive: true }); + await Promise.all([ + writeFile(join(kimiProject, 'alpha', 'SKILL.md'), '# alpha'), + writeFile(join(kimiUser, 'beta', 'SKILL.md'), '# beta'), + ]); + try { + const result = await scanProviderSkillDirs([ + { key: 'kimi-project', provider: 'kimi', path: kimiProject }, + { key: 'kimi-user', provider: 'kimi', path: kimiUser }, + ]); + assert.deepEqual(new Set(result.providerSkills.kimi), new Set(['alpha', 'beta'])); + assert.deepEqual(result.scanResults['kimi-project'], ['alpha']); + assert.deepEqual(result.scanResults['kimi-user'], ['beta']); + assert.equal(result.scansOk, true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('preserves null scan sentinels for failed project scans', async () => { + const { scanProviderSkillDirs } = await import('../dist/routes/capabilities.js'); + const root = join(process.cwd(), '.test-tmp-cap-scan-fail-' + Date.now()); + const kimiUser = join(root, 'home', '.kimi', 'skills'); + await mkdir(join(kimiUser, 'beta'), { recursive: true }); + await writeFile(join(kimiUser, 'beta', 'SKILL.md'), '# beta'); + try { + const result = await scanProviderSkillDirs([ + { key: 'kimi-project', provider: 'kimi', path: join(root, '.missing-unreadable') }, + { key: 'kimi-user', provider: 'kimi', path: kimiUser }, + ]); + assert.equal( + result.scanResults['kimi-project'] === null || Array.isArray(result.scanResults['kimi-project']), + true, + ); + assert.deepEqual(result.scanResults['kimi-user'], ['beta']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + describe('PATCH capabilities logic', () => { /** @type {string} */ let dir; @@ -519,6 +567,32 @@ describe('GET /api/capabilities (Fastify)', () => { await app.close(); }); + it('includes Kimi mount state for cat-cafe skills in the board payload', async () => { + const Fastify = (await import('fastify')).default; + const { capabilitiesRoutes } = await import('../dist/routes/capabilities.js'); + + const app = Fastify(); + await app.register(capabilitiesRoutes); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/api/capabilities', + headers: AUTH_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + + const catCafeSkill = (body.items ?? []).find( + (item) => item.type === 'skill' && item.source === 'cat-cafe' && item.mounts, + ); + assert.ok(catCafeSkill, 'expected at least one cat-cafe skill with mount data'); + assert.equal(typeof catCafeSkill.mounts.kimi, 'boolean'); + + await app.close(); + }); + it('does not treat cat-cafe-skills/refs as a skill', async () => { const Fastify = (await import('fastify')).default; const { capabilitiesRoutes } = await import('../dist/routes/capabilities.js'); @@ -820,4 +894,62 @@ describe('GET /api/capabilities (Fastify)', () => { await rm(projectDir, { recursive: true, force: true }); await app.close(); }); + + it('extracts skill metadata from project-level .kimi/skills SKILL.md', async () => { + const Fastify = (await import('fastify')).default; + const { capabilitiesRoutes } = await import('../dist/routes/capabilities.js'); + + const app = Fastify(); + await app.register(capabilitiesRoutes); + await app.ready(); + + const projectDir = join('/tmp', `cap-kimi-project-meta-${Date.now()}`); + const kimiSkillsDir = join(projectDir, '.kimi', 'skills'); + await mkdir(join(kimiSkillsDir, 'test-skill'), { recursive: true }); + + // Write SKILL.md with frontmatter containing description and triggers + await writeFile( + join(kimiSkillsDir, 'test-skill', 'SKILL.md'), + '---\ndescription: "Test skill for project-level Kimi metadata extraction"\ntriggers: ["test-trigger", "kimi-test"]\n---\n\n# Test Skill\n', + ); + + // Write capabilities.json with the skill + await writeCapabilitiesConfig(projectDir, { + version: 1, + capabilities: [ + { + id: 'test-skill', + type: 'skill', + enabled: true, + source: 'project', + }, + ], + }); + + try { + const res = await app.inject({ + method: 'GET', + url: `/api/capabilities?projectPath=${encodeURIComponent(projectDir)}`, + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200); + + const body = res.json(); + const skillItem = body.items.find((i) => i.type === 'skill' && i.id === 'test-skill'); + assert.ok(skillItem, 'Project-level Kimi skill should appear in board'); + assert.equal( + skillItem.description, + 'Test skill for project-level Kimi metadata extraction', + 'Should extract description from project .kimi/skills SKILL.md', + ); + assert.deepEqual( + skillItem.triggers, + ['test-trigger', 'kimi-test'], + 'Should extract triggers from project .kimi/skills SKILL.md', + ); + } finally { + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); }); diff --git a/packages/api/test/cat-config-loader.test.js b/packages/api/test/cat-config-loader.test.js index 36792929a..c59075c5b 100644 --- a/packages/api/test/cat-config-loader.test.js +++ b/packages/api/test/cat-config-loader.test.js @@ -926,10 +926,10 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.notDeepEqual(all.sonnet.color, all.opus.color); }); - it('total cat count is 12 (opus + sonnet + opus-45 + codex + gpt52 + spark + gemini + gemini25 + dare + antigravity + antig-opus + opencode)', () => { + it('total cat count is 13 (opus + sonnet + opus-45 + codex + gpt52 + spark + gemini + gemini25 + kimi + dare + antigravity + antig-opus + opencode)', () => { const config = loadCatConfig(); const all = toAllCatConfigs(config); - assert.equal(Object.keys(all).length, 12); + assert.equal(Object.keys(all).length, 13); assert.ok(all.opus); assert.ok(all.sonnet); assert.ok(all['opus-45']); @@ -938,6 +938,7 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.ok(all.spark); // F032 Phase E: new cat added assert.ok(all.gemini); assert.ok(all.gemini25); + assert.ok(all.kimi); // Kimi CLI cat (moonshot) assert.ok(all.dare); // F050: DARE external agent (dragon-li) assert.ok(all.antigravity); // F061: Bengal cat (Antigravity CDP bridge) assert.ok(all['antig-opus']); // F061: Bengal cat Claude variant diff --git a/packages/api/test/cats-routes-runtime-crud.test.js b/packages/api/test/cats-routes-runtime-crud.test.js index 169eb8aa3..68bd36efc 100644 --- a/packages/api/test/cats-routes-runtime-crud.test.js +++ b/packages/api/test/cats-routes-runtime-crud.test.js @@ -354,6 +354,51 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.match(JSON.parse(createRes.body).error, /effort/i); }); + it('POST /api/cats accepts kimi client with first-class default CLI commands', async () => { + const projectRoot = createProjectRoot(); + process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + + const app = Fastify(); + await app.register(catsRoutes); + + try { + const kimiRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + catId: 'runtime-kimi', + name: 'Kimi 猫', + displayName: 'Kimi 猫', + avatar: '/avatars/kimi.png', + color: { primary: '#7c3aed', secondary: '#ede9fe' }, + mentionPatterns: ['@runtime-kimi'], + roleDescription: '中文代码助手', + clientId: 'kimi', + accountRef: 'kimi', + defaultModel: 'kimi-k2.5', + }), + }); + assert.equal(kimiRes.statusCode, 201); + + const catalog = JSON.parse(readFileSync(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); + const breeds = catalog.breeds; + const kimiVariant = breeds.find((breed) => breed.catId === 'runtime-kimi')?.variants?.[0]; + + assert.equal(kimiVariant.clientId, 'kimi'); + assert.deepEqual(kimiVariant.cli, { command: 'kimi', outputFormat: 'stream-json' }); + assert.equal(kimiVariant.accountRef, 'kimi'); + } finally { + await app.close(); + } + }); + it('POST /api/cats falls back to the readable active project root when CAT_TEMPLATE_PATH is stale', async () => { const projectRoot = createMonorepoProjectRoot(); const staleRoot = mkdtempSync(join(tmpdir(), 'cats-route-crud-stale-')); diff --git a/packages/api/test/env-registry.test.js b/packages/api/test/env-registry.test.js index f036a6c2c..eed58f2b9 100644 --- a/packages/api/test/env-registry.test.js +++ b/packages/api/test/env-registry.test.js @@ -72,6 +72,22 @@ describe('env-registry', () => { assert.equal(apiKey.sensitive, true); }); + it('registers KIMI_QUOTA_API_FALLBACK_ENABLED as bootstrap-only quota config', () => { + const def = ENV_VARS.find((v) => v.name === 'KIMI_QUOTA_API_FALLBACK_ENABLED'); + assert.ok(def, 'KIMI_QUOTA_API_FALLBACK_ENABLED should be in registry'); + assert.equal(def.category, 'quota'); + assert.equal(def.runtimeEditable, false); + assert.equal(def.hubVisible, false); + }); + + it('registers KIMI_CONFIG_FILE as bootstrap-only kimi config', () => { + const def = ENV_VARS.find((v) => v.name === 'KIMI_CONFIG_FILE'); + assert.ok(def, 'KIMI_CONFIG_FILE should be in registry'); + assert.equal(def.category, 'kimi'); + assert.equal(def.runtimeEditable, false); + assert.equal(def.hubVisible, false); + }); + it('REDIS_URL has maskMode url', () => { const redis = ENV_VARS.find((v) => v.name === 'REDIS_URL'); assert.ok(redis, 'REDIS_URL should be in registry'); diff --git a/packages/api/test/gemini-agent-service.test.js b/packages/api/test/gemini-agent-service.test.js index 50cc10736..cbbf423d5 100644 --- a/packages/api/test/gemini-agent-service.test.js +++ b/packages/api/test/gemini-agent-service.test.js @@ -5,6 +5,9 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, mock, test } from 'node:test'; import { ensureFakeCliOnPath } from './helpers/fake-cli-path.js'; @@ -753,3 +756,108 @@ test('F24: prefers stats.context_window over stats.contextWindow when both exist assert.ok(done?.metadata?.usage, 'done should have usage metadata'); assert.equal(done.metadata.usage.contextWindowSize, 900000); }); + +test('emits wrapped thinking from local Gemini session snapshots when available', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new GeminiAgentService({ spawnFn, adapter: 'gemini-cli' }); + + const fakeHome = mkdtempSync(join(tmpdir(), 'gemini-home-')); + const sessionDir = join(fakeHome, '.gemini', 'tmp', 'clowder-ai', 'chats'); + mkdirSync(sessionDir, { recursive: true }); + const previousHome = process.env.HOME; + process.env.HOME = fakeHome; + + try { + const promise = collect( + service.invoke('test thinking', { workingDirectory: '/Users/liuzifan/Projects/clowder-ai' }), + ); + + emitGeminiEvents(proc, [ + { type: 'init', session_id: 'gem-s1', model: 'gemini-3.1-pro-preview' }, + { type: 'message', role: 'assistant', content: 'Final answer', delta: true }, + { type: 'result', status: 'success', stats: { total_tokens: 123 } }, + ]); + + writeFileSync( + join(sessionDir, 'session-2026-04-06T12-00-test.json'), + JSON.stringify( + { + sessionId: 'gem-s1', + messages: [ + { + id: 'm1', + type: 'gemini', + content: 'Final answer', + thoughts: [ + { subject: 'Planning', description: 'First think.' }, + { subject: 'Checking', description: 'Second think.' }, + ], + }, + ], + }, + null, + 2, + ), + ); + + const msgs = await promise; + const thinkingMsg = msgs.find((m) => m.type === 'system_info' && m.content.includes('"type":"thinking"')); + assert.ok(thinkingMsg, 'should emit thinking system_info'); + const parsed = JSON.parse(thinkingMsg.content); + assert.equal(parsed.type, 'thinking'); + assert.match(parsed.text, /\*\*Planning\*\*/); + assert.match(parsed.text, /Second think\./); + } finally { + process.env.HOME = previousHome; + } +}); + +test('skips Gemini local thinking hydration when the latest session content does not match this reply', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new GeminiAgentService({ spawnFn, adapter: 'gemini-cli' }); + + const fakeHome = mkdtempSync(join(tmpdir(), 'gemini-home-')); + const sessionDir = join(fakeHome, '.gemini', 'tmp', 'clowder-ai', 'chats'); + mkdirSync(sessionDir, { recursive: true }); + const previousHome = process.env.HOME; + process.env.HOME = fakeHome; + + try { + const promise = collect( + service.invoke('test mismatch', { workingDirectory: '/Users/liuzifan/Projects/clowder-ai' }), + ); + + emitGeminiEvents(proc, [ + { type: 'init', session_id: 'gem-s2', model: 'gemini-3.1-pro-preview' }, + { type: 'message', role: 'assistant', content: 'Actual reply', delta: true }, + { type: 'result', status: 'success', stats: { total_tokens: 123 } }, + ]); + + writeFileSync( + join(sessionDir, 'session-2026-04-06T12-01-test.json'), + JSON.stringify( + { + sessionId: 'gem-s2', + messages: [ + { + id: 'm1', + type: 'gemini', + content: 'Some older unrelated reply', + thoughts: [{ subject: 'Old', description: 'Should not attach.' }], + }, + ], + }, + null, + 2, + ), + ); + + const msgs = await promise; + const thinkingMsg = msgs.find((m) => m.type === 'system_info' && m.content.includes('"type":"thinking"')); + assert.equal(thinkingMsg, undefined); + } finally { + process.env.HOME = previousHome; + } +}); diff --git a/packages/api/test/governance/governance-bootstrap.test.js b/packages/api/test/governance/governance-bootstrap.test.js index c7ff42593..c6e5b4dad 100644 --- a/packages/api/test/governance/governance-bootstrap.test.js +++ b/packages/api/test/governance/governance-bootstrap.test.js @@ -35,8 +35,8 @@ describe('GovernanceBootstrapService', () => { assert.equal(report.packVersion, GOVERNANCE_PACK_VERSION); assert.ok(report.actions.length > 0); - // Should create CLAUDE.md, AGENTS.md, GEMINI.md - for (const f of ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) { + // Should create CLAUDE.md, AGENTS.md, GEMINI.md, KIMI.md + for (const f of ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', 'KIMI.md']) { const content = await readFile(join(targetProject, f), 'utf-8'); assert.ok(content.includes(MANAGED_BLOCK_START), `${f} should have managed block start`); assert.ok(content.includes(MANAGED_BLOCK_END), `${f} should have managed block end`); @@ -50,12 +50,12 @@ describe('GovernanceBootstrapService', () => { assert.ok(sop.includes('worktree')); }); - it('creates skills symlinks for all 3 providers', async () => { + it('creates skills symlinks for all 4 providers', async () => { const svc = new GovernanceBootstrapService(catCafeRoot); await svc.bootstrap(targetProject, { dryRun: false }); const sourcePath = resolve(catCafeRoot, 'cat-cafe-skills'); - for (const dir of ['.claude/skills', '.codex/skills', '.gemini/skills']) { + for (const dir of ['.claude/skills', '.codex/skills', '.gemini/skills', '.kimi/skills']) { const linkPath = join(targetProject, dir); const stat = await lstat(linkPath); assert.ok(stat.isSymbolicLink(), `${dir} should be a symlink`); @@ -181,6 +181,17 @@ describe('GovernanceBootstrapService', () => { assert.ok(stat.isSymbolicLink(), '.claude/hooks should be a symlink'); }); + it('creates hooks symlink for kimi provider when source exists', async () => { + await mkdir(join(catCafeRoot, '.kimi', 'hooks'), { recursive: true }); + + const svc = new GovernanceBootstrapService(catCafeRoot); + await svc.bootstrap(targetProject, { dryRun: false }); + + const hooksPath = join(targetProject, '.kimi', 'hooks'); + const stat = await lstat(hooksPath); + assert.ok(stat.isSymbolicLink(), '.kimi/hooks should be a symlink'); + }); + it('skips hooks symlink when source hooks dir does not exist', async () => { // Don't create .claude/hooks in catCafeRoot const svc = new GovernanceBootstrapService(catCafeRoot); diff --git a/packages/api/test/governance/governance-pack.test.js b/packages/api/test/governance/governance-pack.test.js index a6861b6da..f4cdfcdec 100644 --- a/packages/api/test/governance/governance-pack.test.js +++ b/packages/api/test/governance/governance-pack.test.js @@ -54,6 +54,7 @@ describe('governance-pack', () => { assert.ok(getGovernanceManagedBlock('claude').includes('claude')); assert.ok(getGovernanceManagedBlock('codex').includes('codex')); assert.ok(getGovernanceManagedBlock('gemini').includes('gemini')); + assert.ok(getGovernanceManagedBlock('kimi').includes('kimi')); }); it('pack version is semver', () => { diff --git a/packages/api/test/governance/governance-preflight.test.js b/packages/api/test/governance/governance-preflight.test.js index e6630dd13..f55fcb3f0 100644 --- a/packages/api/test/governance/governance-preflight.test.js +++ b/packages/api/test/governance/governance-preflight.test.js @@ -72,7 +72,7 @@ describe('governance-preflight', () => { it('fails when registry confirmed but skills symlinks removed', async () => { const service = new GovernanceBootstrapService(catCafeRoot); await service.bootstrap(externalProject, { dryRun: false }); - for (const dir of ['.claude/skills', '.codex/skills', '.gemini/skills']) { + for (const dir of ['.claude/skills', '.codex/skills', '.gemini/skills', '.kimi/skills']) { await rm(join(externalProject, dir), { force: true }).catch(() => {}); } @@ -86,4 +86,24 @@ describe('governance-preflight', () => { assert.equal(result.ready, false); assert.ok(result.bootstrapCommand, 'Should include a bootstrap command hint'); }); + + it('uses KIMI.md and .kimi/skills when preflighting a kimi project', async () => { + const service = new GovernanceBootstrapService(catCafeRoot); + await service.bootstrap(externalProject, { dryRun: false }); + await rm(join(externalProject, 'KIMI.md')); + + const result = await checkGovernancePreflight(externalProject, catCafeRoot, 'kimi'); + assert.equal(result.ready, false); + assert.ok(result.reason?.includes('KIMI.md')); + }); + + it('requires .kimi/skills when preflighting a kimi project', async () => { + const service = new GovernanceBootstrapService(catCafeRoot); + await service.bootstrap(externalProject, { dryRun: false }); + await rm(join(externalProject, '.kimi/skills'), { force: true }); + + const result = await checkGovernancePreflight(externalProject, catCafeRoot, 'kimi'); + assert.equal(result.ready, false); + assert.ok(result.reason?.includes('.kimi/skills')); + }); }); diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 5ef57d283..bb1ac1121 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -3102,6 +3102,68 @@ describe('invokeSingleCat audit events (P1 fix)', () => { assert.equal(callbackEnv.OPENAI_API_BASE, undefined); }); + it('F127 P1: preserves explicit bound-account failures instead of masking them as generic resolution errors', async () => { + const root = await mkdtemp(join(tmpdir(), 'f127-bound-account-missing-')); + const apiDir = join(root, 'packages', 'api'); + await mkdir(apiDir, { recursive: true }); + await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); + + const registrySnapshot = catRegistry.getAllConfigs(); + const originalConfig = catRegistry.tryGet('codex')?.config; + assert.ok(originalConfig, 'codex config should exist in registry'); + const boundCatId = 'codex-missing-bound-account-test'; + catRegistry.register(boundCatId, { + ...originalConfig, + id: boundCatId, + mentionPatterns: [`@${boundCatId}`], + clientId: 'openai', + accountRef: 'missing-openai-account', + defaultModel: 'gpt-5.4', + }); + + let invokeCount = 0; + const service = { + async *invoke() { + invokeCount++; + yield { type: 'done', catId: 'codex', timestamp: Date.now() }; + }, + }; + + const deps = makeDeps(); + const previousCwd = process.cwd(); + try { + process.chdir(apiDir); + const messages = await collect( + invokeSingleCat(deps, { + catId: boundCatId, + service, + prompt: 'test missing bound account', + userId: 'user-f127-bound-account-missing', + threadId: 'thread-f127-bound-account-missing', + isLastCat: true, + }), + ); + + assert.equal(invokeCount, 0, 'service.invoke should not run when the explicitly bound account is missing'); + assert.ok(messages.some((m) => m.type === 'done')); + assert.ok( + messages.some((m) => m.type === 'error' && m.error === 'bound account "missing-openai-account" not found'), + 'should preserve the specific bound-account failure', + ); + assert.equal( + messages.some((m) => m.type === 'error' && /failed to resolve bound account/i.test(String(m.error))), + false, + ); + } finally { + process.chdir(previousCwd); + catRegistry.reset(); + for (const [id, config] of Object.entries(registrySnapshot)) { + catRegistry.register(id, config); + } + await rm(root, { recursive: true, force: true }); + } + }); + it('F127: ignores legacy api_key protocol metadata when the member explicitly selected the client', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f127-bound-mismatch-')); diff --git a/packages/api/test/kimi-agent-service.test.js b/packages/api/test/kimi-agent-service.test.js new file mode 100644 index 000000000..12227de11 --- /dev/null +++ b/packages/api/test/kimi-agent-service.test.js @@ -0,0 +1,594 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { mock, test } from 'node:test'; + +// Ensure `kimi` is resolvable on CI even when the real CLI is not installed. +// resolveCliCommand uses `which kimi` — placing a stub on PATH satisfies it. +const stubBinDir = mkdtempSync(join(tmpdir(), 'kimi-stub-bin-')); +writeFileSync(join(stubBinDir, 'kimi'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); +process.env.PATH = `${stubBinDir}:${process.env.PATH}`; + +const { KimiAgentService } = await import('../dist/domains/cats/services/agents/providers/KimiAgentService.js'); + +async function collect(iterable) { + const items = []; + for await (const item of iterable) { + items.push(item); + } + return items; +} + +function createMockProcess() { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const emitter = new EventEmitter(); + const proc = { + stdout, + stderr, + pid: 23456, + exitCode: null, + kill: mock.fn(() => { + process.nextTick(() => { + if (!stdout.destroyed) stdout.end(); + emitter.emit('exit', null, 'SIGTERM'); + }); + return true; + }), + on: (event, listener) => { + emitter.on(event, listener); + return proc; + }, + once: (event, listener) => { + emitter.once(event, listener); + return proc; + }, + _emitter: emitter, + }; + return proc; +} + +function createMockSpawnFn(proc) { + return mock.fn(() => proc); +} + +function emitKimiEvents(proc, events) { + for (const event of events) { + proc.stdout.write(`${JSON.stringify(event)}\n`); + } + proc.stdout.end(); + proc._emitter.emit('exit', 0, null); +} + +test('yields text, tool_use, inferred session_init, and done on print-mode success', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-share-')); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-k2.5' }); + + try { + mkdirSync(shareDir, { recursive: true }); + writeFileSync( + join(shareDir, 'kimi.json'), + JSON.stringify( + { + work_dirs: [ + { + path: process.cwd(), + kaos: 'local', + last_session_id: 'kimi-session-123', + }, + ], + }, + null, + 2, + ), + ); + + const promise = collect( + service.invoke('Hello', { + callbackEnv: { KIMI_SHARE_DIR: shareDir }, + }), + ); + + emitKimiEvents(proc, [ + { + role: 'assistant', + thinking: '先思考一下目录结构。', + content: '先看一下目录。', + tool_calls: [ + { + type: 'function', + id: 'tc_1', + function: { + name: 'Shell', + arguments: '{"command":"ls"}', + }, + }, + ], + }, + { role: 'assistant', content: '已经完成。' }, + ]); + + const msgs = await promise; + assert.equal(msgs[0].type, 'system_info'); + assert.match(msgs[0].content, /thinking/); + assert.equal(msgs[1].type, 'text'); + assert.equal(msgs[1].content, '先看一下目录。'); + assert.equal(msgs[2].type, 'tool_use'); + assert.equal(msgs[2].toolName, 'Shell'); + assert.deepEqual(msgs[2].toolInput, { command: 'ls' }); + assert.equal(msgs[3].type, 'text'); + assert.equal(msgs[3].content, '已经完成。'); + assert.equal(msgs[4].type, 'session_init'); + assert.equal(msgs[4].sessionId, 'kimi-session-123'); + assert.equal(msgs[5].type, 'done'); + + const args = spawnFn.mock.calls[0].arguments[1]; + assert.ok(args.includes('--print')); + assert.ok(args.includes('--output-format')); + assert.ok(args.includes('stream-json')); + assert.ok(args.includes('--prompt')); + } finally { + rmSync(shareDir, { recursive: true, force: true }); + } +}); + +test('infers session_init from kimi.json when workingDirectory is a symlink alias', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-share-symlink-')); + const worktreeRoot = mkdtempSync(join(tmpdir(), 'kimi-worktree-real-')); + const worktreeAlias = join(tmpdir(), `kimi-worktree-alias-${Date.now()}`); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-k2.5' }); + + try { + symlinkSync(worktreeRoot, worktreeAlias, process.platform === 'win32' ? 'junction' : 'dir'); + writeFileSync( + join(shareDir, 'kimi.json'), + JSON.stringify( + { + work_dirs: [ + { + path: worktreeRoot, + last_session_id: 'kimi-session-symlink', + }, + ], + }, + null, + 2, + ), + ); + + const promise = collect( + service.invoke('Hello', { + workingDirectory: worktreeAlias, + callbackEnv: { KIMI_SHARE_DIR: shareDir }, + }), + ); + + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + const msgs = await promise; + const sessionInit = msgs.find((msg) => msg.type === 'session_init'); + assert.equal(sessionInit?.sessionId, 'kimi-session-symlink'); + } finally { + rmSync(shareDir, { recursive: true, force: true }); + rmSync(worktreeRoot, { recursive: true, force: true }); + rmSync(worktreeAlias, { recursive: true, force: true }); + } +}); + +test('uses --session for resume and emits session_init immediately', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-k2.5' }); + + const promise = collect(service.invoke('Continue', { sessionId: 'resume-kimi-456' })); + await new Promise((resolve) => setImmediate(resolve)); + emitKimiEvents(proc, [{ role: 'assistant', content: 'Resumed Kimi.' }]); + const msgs = await promise; + + assert.equal(msgs[0].type, 'session_init'); + assert.equal(msgs[0].sessionId, 'resume-kimi-456'); + assert.equal(msgs[1].type, 'text'); + assert.equal(msgs[1].content, 'Resumed Kimi.'); + assert.equal(msgs[2].type, 'system_info'); + assert.match(msgs[2].content, /provider_capability/); + + const args = spawnFn.mock.calls[0].arguments[1]; + const sessionFlagIndex = args.indexOf('--session'); + assert.ok(sessionFlagIndex >= 0); + assert.equal(args[sessionFlagIndex + 1], 'resume-kimi-456'); +}); + +test('maps bare oauth kimi model names to configured model alias', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-config-share-')); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-k2.5' }); + + try { + writeFileSync(join(shareDir, 'config.toml'), 'default_model = "kimi-code/kimi-for-coding"\n', 'utf8'); + const promise = collect( + service.invoke('Hello', { + callbackEnv: { KIMI_SHARE_DIR: shareDir }, + }), + ); + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + const modelFlagIndex = args.indexOf('--model'); + assert.ok(modelFlagIndex >= 0); + assert.equal(args[modelFlagIndex + 1], 'kimi-code/kimi-for-coding'); + } finally { + rmSync(shareDir, { recursive: true, force: true }); + } +}); + +test('api-key mode injects kimi env overrides instead of embedding secrets in argv', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-code/kimi-for-coding' }); + + const promise = collect( + service.invoke('Hello', { + callbackEnv: { + CAT_CAFE_KIMI_API_KEY: 'sk-kimi-secret', + CAT_CAFE_KIMI_BASE_URL: 'https://api.moonshot.ai/v1', + KIMI_SHARE_DIR: mkdtempSync(join(tmpdir(), 'kimi-share-api-key-')), + }, + }), + ); + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + const joined = args.join(' '); + const env = spawnFn.mock.calls[0].arguments[2]?.env ?? {}; + assert.ok(!args.includes('--config-file')); + assert.ok(!args.includes('--model')); + assert.ok(!joined.includes('sk-kimi-secret')); + assert.equal(env.KIMI_API_KEY, 'sk-kimi-secret'); + assert.equal(env.KIMI_BASE_URL, 'https://api.moonshot.ai/v1'); + assert.equal(env.KIMI_MODEL_NAME, 'kimi-code/kimi-for-coding'); +}); + +test('api-key mode maps selected model into official kimi env overrides', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-share-config-shape-')); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-k2.5' }); + + try { + const promise = collect( + service.invoke('Hello', { + callbackEnv: { + CAT_CAFE_KIMI_API_KEY: 'sk-kimi-secret', + CAT_CAFE_KIMI_BASE_URL: 'https://api.moonshot.ai/v1', + KIMI_SHARE_DIR: shareDir, + }, + }), + ); + const args = spawnFn.mock.calls[0].arguments[1]; + const env = spawnFn.mock.calls[0].arguments[2]?.env ?? {}; + assert.ok(!args.includes('--model')); + assert.equal(env.KIMI_MODEL_NAME, 'kimi-k2.5'); + assert.equal(env.KIMI_MODEL_MAX_CONTEXT_SIZE, '262144'); + + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + await promise; + } finally { + rmSync(shareDir, { recursive: true, force: true }); + } +}); + +test('injects cat-cafe MCP config file when callback env is present', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-share-mcp-')); + const projectDir = mkdtempSync(join(tmpdir(), 'kimi-project-mcp-')); + const mcpServerDir = mkdtempSync(join(tmpdir(), 'kimi-mcp-server-')); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ + spawnFn, + model: 'kimi-code/kimi-for-coding', + mcpServerPath: join(mcpServerDir, 'index.js'), + }); + + try { + mkdirSync(join(projectDir, '.kimi'), { recursive: true }); + writeFileSync( + join(projectDir, '.kimi', 'mcp.json'), + JSON.stringify({ + mcpServers: { + filesystem: { command: 'npx', args: ['-y', '@mcp/fs'] }, + }, + }), + 'utf8', + ); + writeFileSync(join(mcpServerDir, 'index.js'), '// stub', 'utf8'); + + const promise = collect( + service.invoke('Hello', { + workingDirectory: projectDir, + callbackEnv: { + KIMI_SHARE_DIR: shareDir, + CAT_CAFE_API_URL: 'http://127.0.0.1:3004', + CAT_CAFE_INVOCATION_ID: 'invoke-123', + CAT_CAFE_CALLBACK_TOKEN: 'token-123', + }, + }), + ); + const args = spawnFn.mock.calls[0].arguments[1]; + const mcpFlagIndex = args.indexOf('--mcp-config-file'); + assert.ok(mcpFlagIndex >= 0); + const mcpPath = args[mcpFlagIndex + 1]; + const mcpConfig = JSON.parse(readFileSync(mcpPath, 'utf8')); + assert.ok(mcpConfig.mcpServers['cat-cafe']); + assert.ok(mcpConfig.mcpServers.filesystem); + assert.equal(mcpConfig.mcpServers['cat-cafe'].command, 'node'); + assert.equal(mcpConfig.mcpServers['cat-cafe'].env.CAT_CAFE_API_URL, 'http://127.0.0.1:3004'); + assert.equal(mcpConfig.mcpServers['cat-cafe'].env.CAT_CAFE_INVOCATION_ID, 'invoke-123'); + assert.equal(mcpConfig.mcpServers['cat-cafe'].env.CAT_CAFE_CALLBACK_TOKEN, 'token-123'); + + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + await promise; + } finally { + rmSync(shareDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(mcpServerDir, { recursive: true, force: true }); + } +}); + +test('creates Kimi share dir before writing temp MCP config on fresh setups', async () => { + const root = mkdtempSync(join(tmpdir(), 'kimi-fresh-root-')); + const shareDir = join(root, 'does-not-exist-yet'); + const projectDir = mkdtempSync(join(tmpdir(), 'kimi-fresh-project-')); + const mcpServerDir = mkdtempSync(join(tmpdir(), 'kimi-fresh-mcp-')); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ + spawnFn, + model: 'kimi-code/kimi-for-coding', + mcpServerPath: join(mcpServerDir, 'index.js'), + }); + + try { + writeFileSync(join(mcpServerDir, 'index.js'), '// stub', 'utf8'); + const promise = collect( + service.invoke('Hello', { + workingDirectory: projectDir, + callbackEnv: { + KIMI_SHARE_DIR: shareDir, + CAT_CAFE_API_URL: 'http://127.0.0.1:3004', + CAT_CAFE_INVOCATION_ID: 'invoke-fresh', + CAT_CAFE_CALLBACK_TOKEN: 'token-fresh', + }, + }), + ); + + const args = spawnFn.mock.calls[0].arguments[1]; + const mcpFlagIndex = args.indexOf('--mcp-config-file'); + assert.ok(mcpFlagIndex >= 0); + const mcpPath = args[mcpFlagIndex + 1]; + assert.ok(readFileSync(mcpPath, 'utf8').includes('cat-cafe')); + + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + const msgs = await promise; + assert.equal(msgs.at(-1)?.type, 'done'); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(mcpServerDir, { recursive: true, force: true }); + } +}); + +test('wraps system prompt separately and adds local image path hints', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-code/kimi-for-coding' }); + const uploadDir = mkdtempSync(join(tmpdir(), 'kimi-upload-')); + const imagePath = join(uploadDir, 'example.png'); + writeFileSync(imagePath, 'fake-image', 'utf8'); + + try { + const promise = collect( + service.invoke('帮我分析图片', { + systemPrompt: '你是梵花猫,回答要简洁。', + contentBlocks: [{ type: 'image', url: '/uploads/example.png' }], + uploadDir, + }), + ); + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + const promptFlagIndex = args.indexOf('--prompt'); + assert.ok(promptFlagIndex >= 0); + const effectivePrompt = args[promptFlagIndex + 1]; + assert.match(effectivePrompt, //); + assert.match(effectivePrompt, /你是梵花猫/); + assert.match(effectivePrompt, /example\.png/); + } finally { + rmSync(uploadDir, { recursive: true, force: true }); + } +}); + +test('enables thinking mode, parses think blocks, and grants image directories to kimi-cli', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-config-cap-')); + const uploadDir = mkdtempSync(join(tmpdir(), 'kimi-image-cap-')); + const imagePath = join(uploadDir, 'diagram.png'); + writeFileSync(imagePath, 'fake-image', 'utf8'); + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-code/kimi-for-coding' }); + + try { + writeFileSync( + join(shareDir, 'config.toml'), + [ + 'default_model = "kimi-code/kimi-for-coding"', + 'default_thinking = true', + '', + '[models."kimi-code/kimi-for-coding"]', + 'capabilities = ["thinking", "image_in"]', + ].join('\n'), + 'utf8', + ); + + const promise = collect( + service.invoke('看看这张图', { + callbackEnv: { KIMI_SHARE_DIR: shareDir }, + contentBlocks: [{ type: 'image', url: '/uploads/diagram.png' }], + uploadDir, + }), + ); + + emitKimiEvents(proc, [ + { + role: 'assistant', + content: [ + { type: 'think', think: '先理解图片里有什么。' }, + { type: 'text', text: '我已经看到图片路径提示。' }, + ], + }, + ]); + + const msgs = await promise; + assert.equal(msgs[0].type, 'system_info'); + assert.match(msgs[0].content, /thinking/); + assert.match(msgs[0].content, /先理解图片/); + assert.equal(msgs[1].type, 'system_info'); + assert.match(msgs[1].content, /image_input/); + assert.match(msgs[1].content, /available/); + assert.equal(msgs[2].type, 'text'); + assert.match(msgs[2].content, /图片路径提示/); + + const args = spawnFn.mock.calls[0].arguments[1]; + assert.ok(args.includes('--thinking')); + const addDirIndex = args.indexOf('--add-dir'); + assert.ok(addDirIndex >= 0); + assert.equal(args[addDirIndex + 1], uploadDir); + } finally { + rmSync(shareDir, { recursive: true, force: true }); + rmSync(uploadDir, { recursive: true, force: true }); + } +}); + +test('does not emit thinking unavailable if a later assistant event includes thinking', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-code/kimi-for-coding' }); + + const promise = collect(service.invoke('Hello')); + emitKimiEvents(proc, [ + { role: 'assistant', content: '先准备一下。' }, + { + role: 'assistant', + content: [ + { type: 'think', think: '这里才给出真正的思考内容。' }, + { type: 'text', text: '最终回答。' }, + ], + }, + ]); + + const msgs = await promise; + const capabilityUnavailable = msgs.find( + (msg) => msg.type === 'system_info' && /provider_capability/.test(msg.content) && /thinking/.test(msg.content), + ); + const thinkingEvent = msgs.find((msg) => msg.type === 'system_info' && /"type":"thinking"/.test(msg.content)); + assert.equal(capabilityUnavailable, undefined); + assert.ok(thinkingEvent, 'should emit a thinking event once think content appears later in the stream'); +}); + +test('extracts session id from non-json resume hint lines in print mode', async () => { + async function* spawnCliOverride() { + yield { + line: 'To resume this session: kimi -r ab5188ae-f3e8-4f72-baec-48a53c665e9a', + error: 'Failed to parse JSON line', + }; + yield { role: 'assistant', content: 'done' }; + } + + const service = new KimiAgentService({ model: 'kimi-code/kimi-for-coding' }); + const msgs = await collect(service.invoke('Hello', { spawnCliOverride })); + const session = msgs.find((msg) => msg.type === 'session_init'); + assert.equal(session?.sessionId, 'ab5188ae-f3e8-4f72-baec-48a53c665e9a'); +}); + +test('captures usage and session id from kimi stream events when available', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-code/kimi-for-coding' }); + + const promise = collect(service.invoke('Hello')); + emitKimiEvents(proc, [ + { + role: 'assistant', + session_id: 'kimi-live-session', + usage: { + input_tokens: 12, + output_tokens: 34, + total_tokens: 46, + }, + content: 'ok', + }, + ]); + const msgs = await promise; + const session = msgs.find((msg) => msg.type === 'session_init'); + const text = msgs.find((msg) => msg.type === 'text'); + assert.equal(session?.sessionId, 'kimi-live-session'); + assert.equal(text?.metadata?.sessionId, 'kimi-live-session'); + assert.equal(text?.metadata?.usage?.inputTokens, 12); + assert.equal(text?.metadata?.usage?.outputTokens, 34); + assert.equal(text?.metadata?.usage?.totalTokens, 46); +}); + +test('enriches done metadata with local Kimi context snapshot for session-chain health', async () => { + const shareDir = mkdtempSync(join(tmpdir(), 'kimi-context-share-')); + const sessionId = 'kimi-context-session'; + const sessionDir = join(shareDir, 'sessions', 'project-hash', sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(shareDir, 'config.toml'), + [ + 'default_model = "kimi-code/kimi-for-coding"', + '', + '[models."kimi-code/kimi-for-coding"]', + 'max_context_size = 262144', + 'capabilities = ["thinking", "image_in"]', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(sessionDir, 'context.jsonl'), + ['{"role":"user","content":"hi"}', '{"role":"_usage","token_count":6335}'].join('\n'), + 'utf8', + ); + + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new KimiAgentService({ spawnFn, model: 'kimi-code/kimi-for-coding' }); + + try { + const promise = collect( + service.invoke('Hello', { + sessionId, + callbackEnv: { KIMI_SHARE_DIR: shareDir }, + }), + ); + await new Promise((resolve) => setImmediate(resolve)); + emitKimiEvents(proc, [{ role: 'assistant', content: 'ok' }]); + const msgs = await promise; + const done = msgs.find((msg) => msg.type === 'done'); + assert.ok(done?.metadata?.usage, 'done should have usage metadata'); + assert.equal(done.metadata.usage.contextUsedTokens, 6335); + assert.equal(done.metadata.usage.contextWindowSize, 262144); + assert.equal(done.metadata.usage.lastTurnInputTokens, 6335); + } finally { + rmSync(shareDir, { recursive: true, force: true }); + } +}); diff --git a/packages/api/test/mcp-config-adapters.test.js b/packages/api/test/mcp-config-adapters.test.js index 90b43104a..ce06d22e0 100644 --- a/packages/api/test/mcp-config-adapters.test.js +++ b/packages/api/test/mcp-config-adapters.test.js @@ -11,9 +11,11 @@ import { readClaudeMcpConfig, readCodexMcpConfig, readGeminiMcpConfig, + readKimiMcpConfig, writeClaudeMcpConfig, writeCodexMcpConfig, writeGeminiMcpConfig, + writeKimiMcpConfig, } from '../dist/config/capabilities/mcp-config-adapters.js'; /** @param {string} prefix */ @@ -229,6 +231,44 @@ describe('readGeminiMcpConfig', () => { }); }); +describe('readKimiMcpConfig', () => { + /** @type {string} */ let dir; + + beforeEach(async () => { + dir = await makeTmpDir('kimi-read'); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses ~/.kimi/mcp.json compatible config', async () => { + const file = join(dir, 'mcp.json'); + await writeFile( + file, + JSON.stringify({ + mcpServers: { + context7: { + url: 'https://mcp.context7.com/mcp', + headers: { CONTEXT7_API_KEY: 'test-key' }, + }, + filesystem: { + command: 'npx', + args: ['-y', '@mcp/fs'], + env: { DEBUG: '1' }, + }, + }, + }), + ); + + const result = await readKimiMcpConfig(file); + assert.equal(result.length, 2); + const remote = result.find((server) => server.name === 'context7'); + assert.equal(remote?.transport, 'streamableHttp'); + assert.equal(remote?.url, 'https://mcp.context7.com/mcp'); + assert.deepEqual(remote?.headers, { CONTEXT7_API_KEY: 'test-key' }); + }); +}); + // ────────── Writers ────────── describe('writeClaudeMcpConfig', () => { @@ -421,6 +461,74 @@ describe('writeGeminiMcpConfig', () => { }); }); +describe('writeKimiMcpConfig', () => { + /** @type {string} */ let dir; + + beforeEach(async () => { + dir = await makeTmpDir('kimi-write'); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('writes stdio and http MCP servers in kimi format', async () => { + const file = join(dir, 'mcp.json'); + await writeKimiMcpConfig(file, [ + { + name: 'context7', + command: '', + args: [], + enabled: true, + source: 'external', + transport: 'streamableHttp', + url: 'https://mcp.context7.com/mcp', + headers: { CONTEXT7_API_KEY: 'test-key' }, + }, + { + name: 'filesystem', + command: 'npx', + args: ['-y', '@mcp/fs'], + enabled: true, + source: 'external', + env: { DEBUG: '1' }, + }, + ]); + + const raw = JSON.parse(await readFile(file, 'utf-8')); + assert.deepEqual(raw.mcpServers.context7, { + url: 'https://mcp.context7.com/mcp', + headers: { CONTEXT7_API_KEY: 'test-key' }, + }); + assert.deepEqual(raw.mcpServers.filesystem, { + command: 'npx', + args: ['-y', '@mcp/fs'], + env: { DEBUG: '1' }, + }); + }); + + it('injects cat-cafe callback env placeholders for kimi cat-cafe servers', async () => { + const file = join(dir, 'mcp.json'); + await writeKimiMcpConfig(file, [ + { + name: 'cat-cafe', + command: 'node', + args: ['index.js'], + enabled: true, + source: 'cat-cafe', + }, + ]); + + const raw = JSON.parse(await readFile(file, 'utf-8')); + assert.deepEqual(raw.mcpServers['cat-cafe'].env, { + CAT_CAFE_API_URL: '${CAT_CAFE_API_URL}', + CAT_CAFE_INVOCATION_ID: '${CAT_CAFE_INVOCATION_ID}', + CAT_CAFE_CALLBACK_TOKEN: '${CAT_CAFE_CALLBACK_TOKEN}', + CAT_CAFE_USER_ID: '${CAT_CAFE_USER_ID}', + CAT_CAFE_SIGNAL_USER: '${CAT_CAFE_SIGNAL_USER}', + }); + }); +}); + // ────────── P1-2 Regression: Preserve user's non-managed MCP servers ────────── describe('P1-2: writers preserve non-managed MCP servers', () => { diff --git a/packages/api/test/provider-profiles-kimi.test.js b/packages/api/test/provider-profiles-kimi.test.js new file mode 100644 index 000000000..e5273dfcb --- /dev/null +++ b/packages/api/test/provider-profiles-kimi.test.js @@ -0,0 +1,52 @@ +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +const AUTH_HEADERS = { 'x-cat-cafe-user': 'test-user' }; + +test('accounts route accepts kimi api_key account creation', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await mkdtemp(join(tmpdir(), 'accounts-kimi-')); + const previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectDir; + + try { + const createRes = await app.inject({ + method: 'POST', + url: '/api/accounts', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + displayName: 'Moonshot', + authType: 'api_key', + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-kimi', + models: ['kimi-k2.5'], + }), + }); + assert.equal(createRes.statusCode, 200, `create failed: ${createRes.body}`); + + const listRes = await app.inject({ + method: 'GET', + url: `/api/accounts?projectPath=${encodeURIComponent(projectDir)}`, + headers: AUTH_HEADERS, + }); + assert.equal(listRes.statusCode, 200); + const account = listRes.json().providers.find((entry) => entry.displayName === 'Moonshot'); + assert.ok(account, 'Kimi account should be listed'); + assert.equal(account.kind, 'api_key'); + } finally { + if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } +}); diff --git a/packages/api/test/quota-api.test.js b/packages/api/test/quota-api.test.js index da0276cd4..a4c18f478 100644 --- a/packages/api/test/quota-api.test.js +++ b/packages/api/test/quota-api.test.js @@ -28,6 +28,7 @@ describe('GET /api/quota', () => { const body = res.json(); assert.equal(body.claude.platform, 'claude'); assert.equal(body.codex.platform, 'codex'); + assert.equal(body.kimi.platform, 'kimi'); assert.equal(body.antigravity.platform, 'antigravity'); assert.ok(body.fetchedAt); } finally { @@ -70,6 +71,18 @@ describe('GET /api/quota', () => { await app.close(); } }); + + it('returns Kimi as unavailable before any official refresh', async () => { + const app = await buildApp(); + try { + const res = await app.inject({ method: 'GET', url: '/api/quota' }); + const body = res.json(); + assert.equal(body.kimi.status, 'unavailable'); + assert.deepEqual(body.kimi.usageItems, []); + } finally { + await app.close(); + } + }); }); describe('GET /api/quota/probes', () => { @@ -78,13 +91,18 @@ describe('GET /api/quota/probes', () => { delete process.env.QUOTA_OFFICIAL_REFRESH_ENABLED; const app = await buildApp(); try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => []); const res = await app.inject({ method: 'GET', url: '/api/quota/probes' }); assert.equal(res.statusCode, 200); const body = res.json(); assert.equal(Array.isArray(body.probes), true); const official = body.probes.find((probe) => probe.id === 'official-browser'); + const kimi = body.probes.find((probe) => probe.id === 'kimi-cli'); assert.equal(official?.enabled, false); assert.equal(official?.status, 'disabled'); + assert.ok(kimi, 'should expose kimi probe'); + assert.deepEqual(kimi?.targets, ['kimi']); assert.deepEqual(official?.targets, ['codex', 'claude']); assert.equal(official?.actions?.[0]?.path, '/api/quota/refresh/official'); assert.equal(official?.actions?.[0]?.requiresInteractive, false); @@ -96,6 +114,21 @@ describe('GET /api/quota/probes', () => { } }); + it('exposes a manual refresh action for Kimi official quota', async () => { + const app = await buildApp(); + try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => []); + const res = await app.inject({ method: 'GET', url: '/api/quota/probes' }); + const body = res.json(); + const kimi = body.probes.find((probe) => probe.id === 'kimi-cli'); + assert.equal(kimi?.actions?.[0]?.path, '/api/quota/refresh/kimi'); + assert.equal(kimi?.actions?.[0]?.requiresInteractive, false); + } finally { + await app.close(); + } + }); + it('marks official-browser probe enabled when env toggle is set', async () => { const oldEnabled = process.env.QUOTA_OFFICIAL_REFRESH_ENABLED; process.env.QUOTA_OFFICIAL_REFRESH_ENABLED = '1'; @@ -115,6 +148,23 @@ describe('GET /api/quota/probes', () => { } }); + it('marks kimi-cli status=error when Kimi is refreshable but no quota data has been loaded yet', async () => { + const app = await buildApp(); + try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => []); + const res = await app.inject({ method: 'GET', url: '/api/quota/probes' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + const kimi = body.probes.find((probe) => probe.id === 'kimi-cli'); + assert.equal(kimi?.enabled, true); + assert.equal(kimi?.status, 'error'); + assert.match(kimi?.reason ?? '', /暂无 Kimi CLI 额度数据|Kimi/i); + } finally { + await app.close(); + } + }); + it('marks official-browser probe status=error after official refresh failure', async () => { const oldEnabled = process.env.QUOTA_OFFICIAL_REFRESH_ENABLED; process.env.QUOTA_OFFICIAL_REFRESH_ENABLED = '1'; @@ -162,6 +212,7 @@ describe('GET /api/quota/summary', () => { assert.equal(body.platforms.codex.label, '缅因猫 (Codex + GPT-5.2)'); assert.equal(typeof body.platforms.codex.displayPercent, 'number'); assert.equal(typeof body.probes.official.status, 'string'); + assert.equal(typeof body.probes.kimi.status, 'string'); assert.equal(typeof body.actions.refreshOfficialPath, 'string'); } finally { if (oldEnabled != null) process.env.QUOTA_OFFICIAL_REFRESH_ENABLED = oldEnabled; @@ -170,6 +221,44 @@ describe('GET /api/quota/summary', () => { } }); + it('surfaces the same non-ok Kimi probe status through /api/quota/summary', async () => { + const app = await buildApp(); + try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => []); + const res = await app.inject({ method: 'GET', url: '/api/quota/summary' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.probes.kimi.enabled, true); + assert.equal(body.probes.kimi.status, 'error'); + assert.match(body.probes.kimi.reason ?? '', /暂无 Kimi CLI 额度数据|Kimi/i); + } finally { + await app.close(); + } + }); + + it('includes Kimi utilization in summary risk calculations', async () => { + const app = await buildApp(); + try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => [ + { label: '每周使用限额', usedPercent: 97, percentKind: 'used', poolId: 'kimi-weekly' }, + ]); + await app.inject({ method: 'POST', url: '/api/quota/refresh/kimi' }); + const res = await app.inject({ method: 'GET', url: '/api/quota/summary' }); + const body = res.json(); + assert.equal(body.platforms.kimi.status, 'error'); + assert.equal(body.risk.level, 'high'); + assert.equal( + body.risk.reasons.some((reason) => /97%/.test(String(reason))), + true, + ); + assert.equal(body.actions.refreshKimiPath, '/api/quota/refresh/kimi'); + } finally { + await app.close(); + } + }); + it('flags high risk when utilization crosses threshold', async () => { const oldEnabled = process.env.QUOTA_OFFICIAL_REFRESH_ENABLED; process.env.QUOTA_OFFICIAL_REFRESH_ENABLED = '1'; @@ -486,6 +575,67 @@ describe('PATCH /api/quota/antigravity', () => { }); }); +describe('POST /api/quota/refresh/kimi', () => { + it('refreshes Kimi quota through the CLI by default', async () => { + const app = await buildApp(); + try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => [ + { label: '每周使用限额', usedPercent: 97, percentKind: 'remaining', poolId: 'kimi-weekly' }, + { label: '5小时使用限额', usedPercent: 76, percentKind: 'remaining', poolId: 'kimi-rate-limit' }, + ]); + const res = await app.inject({ method: 'POST', url: '/api/quota/refresh/kimi' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.kimi.status, 'ok'); + assert.equal(body.source, 'cli'); + assert.equal(body.fallbackUsed, false); + assert.equal(body.kimi.usageItems[0].label, '每周使用限额'); + assert.equal(body.kimi.usageItems[0].usedPercent, 97); + } finally { + await app.close(); + } + }); + + it('falls back to the Kimi API only when env-gated fallback is enabled', async () => { + const oldToken = process.env.KIMI_AUTH_TOKEN; + const oldFallback = process.env.KIMI_QUOTA_API_FALLBACK_ENABLED; + const previousFetch = globalThis.fetch; + process.env.KIMI_AUTH_TOKEN = 'header.payload.signature'; + process.env.KIMI_QUOTA_API_FALLBACK_ENABLED = '1'; + globalThis.fetch = async (url) => { + if (String(url).includes('kimi.gateway.billing')) { + return new Response(JSON.stringify(MOCK_KIMI_USAGE_RESPONSE), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('{}', { status: 404 }); + }; + const app = await buildApp(); + try { + const quotaModule = await import('../dist/routes/quota.js'); + quotaModule.setKimiCliProbeOverrideForTests?.(async () => { + throw new Error('mock kimi cli failure'); + }); + const res = await app.inject({ method: 'POST', url: '/api/quota/refresh/kimi' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.source, 'api'); + assert.equal(body.fallbackUsed, true); + assert.equal(body.kimi.status, 'ok'); + assert.match(body.kimi.note ?? '', /降级到 Kimi API/); + } finally { + if (oldToken != null) process.env.KIMI_AUTH_TOKEN = oldToken; + else delete process.env.KIMI_AUTH_TOKEN; + if (oldFallback != null) process.env.KIMI_QUOTA_API_FALLBACK_ENABLED = oldFallback; + else delete process.env.KIMI_QUOTA_API_FALLBACK_ENABLED; + globalThis.fetch = previousFetch; + await app.close(); + } + }); +}); + describe('POST /api/quota/refresh/official', () => { it('returns 503 when official refresh is disabled by default', async () => { const oldEnabled = process.env.QUOTA_OFFICIAL_REFRESH_ENABLED; @@ -549,6 +699,31 @@ const MOCK_CODEX_WHAM_RESPONSE = { credits_balance: 0, }; +const MOCK_KIMI_USAGE_RESPONSE = { + usages: [ + { + scope: 'FEATURE_CODING', + detail: { + limit: '1000', + used: '970', + remaining: '30', + resetTime: '2026-03-09T19:10:00Z', + }, + limits: [ + { + window: { duration: 5, timeUnit: 'hour' }, + detail: { + limit: '50', + used: '12', + remaining: '38', + resetTime: '2026-03-05T07:10:00Z', + }, + }, + ], + }, + ], +}; + describe('Claude OAuth API parser (v3)', () => { it('parses Anthropic OAuth usage response into usageItems with poolId', async () => { const { parseClaudeOAuthUsageResponse } = await import('../dist/routes/quota.js'); @@ -652,6 +827,37 @@ describe('Codex Wham API parser (v3)', () => { }); }); +describe('Kimi usage parsers', () => { + it('parses weekly and 5-hour quotas from Kimi CLI /usage output', async () => { + const { parseKimiCliUsageOutput } = await import('../dist/routes/quota.js'); + const items = parseKimiCliUsageOutput(` +╭─────────────────────────────── API Usage ───────────────────────────────╮ +│ Weekly limit ━━━━━━━━━━━━━━━━━━━━ 100% left (resets in 6d 23h 22m) │ +│ 5h limit ━━━━━━━━━━━━━━━━━━━━ 75% left (resets in 4h 22m) │ +╰─────────────────────────────────────────────────────────────────────────╯ +`); + assert.deepEqual( + items.map((item) => [item.label, item.usedPercent, item.percentKind, item.poolId]), + [ + ['每周使用限额', 100, 'remaining', 'kimi-weekly'], + ['5小时使用限额', 75, 'remaining', 'kimi-rate-limit'], + ], + ); + }); + + it('parses weekly and 5-hour windows from Kimi billing response', async () => { + const { parseKimiOfficialUsageResponse } = await import('../dist/routes/quota.js'); + const items = parseKimiOfficialUsageResponse(MOCK_KIMI_USAGE_RESPONSE); + assert.deepEqual( + items.map((item) => [item.label, item.usedPercent, item.poolId]), + [ + ['每周使用限额', 97, 'kimi-weekly'], + ['5小时使用限额', 24, 'kimi-rate-limit'], + ], + ); + }); +}); + describe('POST /api/quota/refresh/official — v3 OAuth flow', () => { it('fetches Claude usage via Anthropic OAuth API and updates cache', async () => { const { refreshOfficialQuotaViaOAuth, resetQuotaCachesForTests } = await import('../dist/routes/quota.js'); @@ -791,6 +997,25 @@ describe('POST /api/quota/refresh/official — v3 OAuth flow', () => { assert.ok(result.codex?.items > 0); }); + it('fetches Kimi official usage via billing API and updates cache', async () => { + const { refreshOfficialQuotaViaOAuth, resetQuotaCachesForTests } = await import('../dist/routes/quota.js'); + resetQuotaCachesForTests?.(); + const result = await refreshOfficialQuotaViaOAuth({ + claudeCredentials: null, + codexCredentials: null, + kimiAuthToken: 'header.payload.signature', + fetchLike: async (url) => { + if (String(url).includes('kimi.gateway.billing')) { + return new Response(JSON.stringify(MOCK_KIMI_USAGE_RESPONSE), { status: 200 }); + } + return new Response('', { status: 404 }); + }, + }); + assert.ok(result.kimi); + assert.ok(result.kimi.items > 0); + assert.ok(!result.kimi.error); + }); + it('skips provider when credentials are null', async () => { const { refreshOfficialQuotaViaOAuth, resetQuotaCachesForTests } = await import('../dist/routes/quota.js'); resetQuotaCachesForTests?.(); @@ -801,15 +1026,17 @@ describe('POST /api/quota/refresh/official — v3 OAuth flow', () => { }); assert.equal(result.claude, undefined); assert.equal(result.codex, undefined); + assert.equal(result.kimi, undefined); }); it('reports skipped providers in result', async () => { const { refreshOfficialQuotaViaOAuth, resetQuotaCachesForTests } = await import('../dist/routes/quota.js'); resetQuotaCachesForTests?.(); - // Only Claude has credentials, Codex should be reported as skipped + // Only Claude has credentials; Codex and Kimi should be reported as skipped const result = await refreshOfficialQuotaViaOAuth({ claudeCredentials: { accessToken: 'ok', refreshToken: 'ok' }, codexCredentials: null, + kimiAuthToken: null, fetchLike: async (url) => { if (String(url).includes('anthropic.com')) { return new Response(JSON.stringify(MOCK_CLAUDE_OAUTH_RESPONSE), { status: 200 }); @@ -822,6 +1049,7 @@ describe('POST /api/quota/refresh/official — v3 OAuth flow', () => { // Result should have a skipped array indicating which providers were skipped assert.ok(Array.isArray(result.skipped), 'result should have skipped array'); assert.ok(result.skipped.includes('codex'), 'codex should be in skipped list'); + assert.ok(result.skipped.includes('kimi'), 'kimi should be in skipped list'); }); it('sends form-encoded OAuth refresh request (not JSON)', async () => { diff --git a/packages/api/test/runtime-worktree-script.test.js b/packages/api/test/runtime-worktree-script.test.js index e40bf3e82..26aaecc96 100644 --- a/packages/api/test/runtime-worktree-script.test.js +++ b/packages/api/test/runtime-worktree-script.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawn, spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { tmpdir } from 'node:os'; @@ -345,4 +345,47 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, assert.match(result.stderr, /API port appears active/); assert.doesNotMatch(result.stdout, /STARTED:/); }); + + it('auto-stashes isolated pnpm lock drift before sync during start', () => { + const projectDir = createTempProject('runtime-lock-drift-start'); + const runtimeDir = mkdtempSync(join(tmpdir(), 'runtime-lock-drift-worktree-')); + const remoteDir = mkdtempSync(join(tmpdir(), 'runtime-lock-drift-remote-')); + tempDirs.push(runtimeDir, remoteDir); + + writeFileSync(join(projectDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n', 'utf8'); + execFileSync('git', ['init', '-b', 'main'], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['add', '.'], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: projectDir, stdio: 'ignore' }); + + execFileSync('git', ['init', '--bare', remoteDir], { stdio: 'ignore' }); + execFileSync('git', ['remote', 'add', 'origin', remoteDir], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['push', '-u', 'origin', 'main'], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['fetch', 'origin', 'main'], { cwd: projectDir, stdio: 'ignore' }); + execFileSync('git', ['worktree', 'add', runtimeDir, '-b', 'runtime/main-sync', 'origin/main'], { + cwd: projectDir, + stdio: 'ignore', + }); + const normalizedRuntimeDir = realpathSync(runtimeDir); + + writeFileSync(join(normalizedRuntimeDir, 'pnpm-lock.yaml'), 'lockfileVersion: 8\n', 'utf8'); + const env = withStubbedPnpmEnv(normalizedRuntimeDir); + + const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--daemon'], { + cwd: projectDir, + encoding: 'utf8', + env: { + ...env, + CAT_CAFE_RUNTIME_RESTART_OK: '1', + CAT_CAFE_RUNTIME_DIR: normalizedRuntimeDir, + }, + }); + + assert.equal(result.status, 0, `exit=${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(result.stdout, /lock drift detected/i); + assert.match(result.stdout, /STARTED:/); + const dirty = execFileSync('git', ['diff', '--name-only'], { cwd: normalizedRuntimeDir, encoding: 'utf8' }).trim(); + assert.equal(dirty, ''); + }); }); diff --git a/packages/api/test/skills-route.test.js b/packages/api/test/skills-route.test.js index 25e87b285..8ef13e9ef 100644 --- a/packages/api/test/skills-route.test.js +++ b/packages/api/test/skills-route.test.js @@ -78,6 +78,7 @@ describe('Skills Route', () => { assert.equal(typeof skill.mounts.claude, 'boolean'); assert.equal(typeof skill.mounts.codex, 'boolean'); assert.equal(typeof skill.mounts.gemini, 'boolean'); + assert.equal(typeof skill.mounts.kimi, 'boolean'); } await app.close(); diff --git a/packages/api/test/system-prompt-builder.test.js b/packages/api/test/system-prompt-builder.test.js index 2a568f6c6..936580030 100644 --- a/packages/api/test/system-prompt-builder.test.js +++ b/packages/api/test/system-prompt-builder.test.js @@ -176,7 +176,7 @@ describe('SystemPromptBuilder', () => { mcpAvailable: true, promptTags: ['critique'], }); - assert.ok(prompt.length < 3500, `Prompt is ${prompt.length} chars, expected < 3500`); + assert.ok(prompt.length < 3600, `Prompt is ${prompt.length} chars, expected < 3600`); }); test('returns empty string for unknown catId', async () => { @@ -709,7 +709,7 @@ describe('SystemPromptBuilder', () => { { catId: 'opus', lastMessageAt: Date.now() - 1000, messageCount: 3 }, ], }); - assert.ok(prompt.length < 3500, `Prompt with activity is ${prompt.length} chars, expected < 3500`); + assert.ok(prompt.length < 3600, `Prompt with activity is ${prompt.length} chars, expected < 3600`); }); // --- F042: pinned identity constant + direct-message reply target --- @@ -909,7 +909,7 @@ describe('SystemPromptBuilder', () => { featureId: 'F073', }, }); - assert.ok(prompt.length < 3550, `Prompt with SOP hint is ${prompt.length} chars, expected < 3550`); + assert.ok(prompt.length < 3650, `Prompt with SOP hint is ${prompt.length} chars, expected < 3650`); }); // --- F092: Voice Mode prompt injection --- @@ -956,7 +956,7 @@ describe('SystemPromptBuilder', () => { }, voiceMode: true, }); - assert.ok(prompt.length < 3650, `Prompt with voice mode + SOP hint is ${prompt.length} chars, expected < 3650`); + assert.ok(prompt.length < 3750, `Prompt with voice mode + SOP hint is ${prompt.length} chars, expected < 3750`); }); test('buildInvocationContext injects bootcamp mode when bootcampState provided', async () => { diff --git a/packages/api/test/threads-endpoint.test.js b/packages/api/test/threads-endpoint.test.js index d470009a4..b63a8fbe4 100644 --- a/packages/api/test/threads-endpoint.test.js +++ b/packages/api/test/threads-endpoint.test.js @@ -40,6 +40,17 @@ describe('Thread API', () => { assert.deepEqual(body.participants, []); }); + it('POST /api/threads keeps omitted projectPath as default', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/threads', + payload: { userId: 'alice', title: 'Lobby Chat' }, + }); + assert.equal(res.statusCode, 201); + const body = JSON.parse(res.body); + assert.equal(body.projectPath, 'default'); + }); + it('POST /api/threads with pinned=true creates a pinned thread', async () => { const res = await app.inject({ method: 'POST', diff --git a/packages/api/test/usage-aggregator.test.js b/packages/api/test/usage-aggregator.test.js index 2503a6ee6..3539605e2 100644 --- a/packages/api/test/usage-aggregator.test.js +++ b/packages/api/test/usage-aggregator.test.js @@ -287,6 +287,33 @@ describe('aggregateUsageByDay', () => { assert.equal(result.daily[0].cats.opus.costUsd, 0); }); + test('falls back to lastTurnInputTokens/contextUsedTokens for providers without normalized input tokens', async () => { + const { aggregateUsageByDay } = await import('../dist/domains/cats/services/usage-aggregator.js'); + const anchor = todayNoon(); + const records = [ + makeRecord('inv-kimi-fallback', anchor, { + kimi: { + lastTurnInputTokens: 6335, + contextUsedTokens: 6335, + }, + }), + makeRecord('inv-kimi-total-fallback', anchor, { + kimi: { + totalTokens: 900, + }, + }), + ]; + + const result = aggregateUsageByDay(records, { days: 7 }); + + assert.equal(result.daily[0].cats.kimi.inputTokens, 7235); + assert.equal(result.daily[0].cats.kimi.outputTokens, 0); + assert.equal(result.daily[0].cats.kimi.participations, 2); + assert.equal(result.daily[0].total.inputTokens, 7235); + assert.equal(result.daily[0].total.outputTokens, 0); + assert.equal(result.daily[0].total.invocations, 2); + }); + test('cross-midnight: usageRecordedAt on next day overrides createdAt/updatedAt', async () => { const { aggregateUsageByDay } = await import('../dist/domains/cats/services/usage-aggregator.js'); const anchor = todayNoon(); diff --git a/packages/api/test/windows-portable-redis-tools.test.js b/packages/api/test/windows-portable-redis-tools.test.js index b82d1a141..a93784c31 100644 --- a/packages/api/test/windows-portable-redis-tools.test.js +++ b/packages/api/test/windows-portable-redis-tools.test.js @@ -220,6 +220,7 @@ test('Windows installer uses interactive selectors instead of typed or letter-ba assert.match(installScript, /Name = "Claude"; Label = "Claude"; Cmd = "claude"/); assert.match(installScript, /Name = "Codex"; Label = "Codex"; Cmd = "codex"/); assert.match(installScript, /Name = "Gemini"; Label = "Gemini"; Cmd = "gemini"/); + assert.match(installScript, /Name = "Kimi"; Label = "Kimi"; Cmd = "kimi"/); assert.match(installScript, /Select-InstallerMultiChoice -Title "Missing agent CLIs"/); assert.doesNotMatch(uiHelpersScript, /Label = "&All"/); assert.doesNotMatch(uiHelpersScript, /Label = "&Select"/); @@ -229,6 +230,7 @@ test('Windows installer uses interactive selectors instead of typed or letter-ba assert.match(helpersScript, /Select-InstallerChoice -Title "Claude auth"/); assert.match(helpersScript, /Select-InstallerChoice -Title "Codex auth"/); assert.match(helpersScript, /Select-InstallerChoice -Title "Gemini auth"/); + assert.match(helpersScript, /Select-InstallerChoice -Title "Kimi auth"/); assert.doesNotMatch(helpersScript, /Read-Host " {4}Choose \[1\/2\]/); }); @@ -239,7 +241,11 @@ test('Windows installer masks provider API key prompts instead of echoing secret assert.match(helpersScript, /ZeroFreeBSTR/); const apiPromptMatches = helpersScript.match(/\$apiKey = Read-InstallerSecret " {4}API Key"/g) ?? []; - assert.equal(apiPromptMatches.length, 3, 'expected Claude, Codex, and Gemini API key prompts to use masked input'); + assert.equal( + apiPromptMatches.length, + 4, + 'expected Claude, Codex, Gemini, and Kimi API key prompts to use masked input', + ); assert.doesNotMatch(helpersScript, /\$apiKey = Read-Host " {4}API Key"/); }); diff --git a/packages/shared/src/types/cat-breed.ts b/packages/shared/src/types/cat-breed.ts index 21bf63b34..747c75b58 100644 --- a/packages/shared/src/types/cat-breed.ts +++ b/packages/shared/src/types/cat-breed.ts @@ -196,7 +196,7 @@ export interface ReviewPolicy { // ── F136 Phase 4: Account config types ────────────────────────────────── /** Protocol that the LLM endpoint speaks. */ -export type AccountProtocol = 'anthropic' | 'openai' | 'openai-responses' | 'google'; +export type AccountProtocol = 'anthropic' | 'openai' | 'openai-responses' | 'google' | 'kimi'; /** * Account configuration — lives in ~/.cat-cafe/accounts.json (global). diff --git a/packages/shared/src/types/cat.ts b/packages/shared/src/types/cat.ts index e7c40c73a..eca429df7 100644 --- a/packages/shared/src/types/cat.ts +++ b/packages/shared/src/types/cat.ts @@ -11,7 +11,7 @@ import { createCatId } from './ids.js'; * CLI client identity used to invoke a cat (e.g. 'anthropic' → claude CLI, 'openai' → codex CLI). * Renamed from CatProvider in F340 P5. */ -export type ClientId = 'anthropic' | 'openai' | 'google' | 'dare' | 'antigravity' | 'opencode' | 'a2a'; +export type ClientId = 'anthropic' | 'openai' | 'google' | 'kimi' | 'dare' | 'antigravity' | 'opencode' | 'a2a'; /** @deprecated F340: Use {@link ClientId} instead. Kept as alias for backward compatibility. */ export type CatProvider = ClientId; @@ -145,6 +145,23 @@ export const CAT_CONFIGS: Record = { roleDescription: '视觉设计师和创意顾问,擅长 UI/UX 设计和视觉表达', personality: '活泼有创意,善于用视觉语言表达想法,喜欢尝试新事物', }, + kimi: { + id: createCatId('kimi'), + name: '梵花猫', + displayName: '梵花猫', + avatar: '/avatars/kimi.png', + color: { + primary: '#4B5563', + secondary: '#E5E7EB', + }, + mentionPatterns: ['@kimi', '@moonshot', '@月之暗面', '@梵花猫'], + clientId: 'kimi', + defaultModel: 'kimi-code/kimi-for-coding', + mcpSupport: true, + breedId: 'moonshot', + roleDescription: '中文推理与长文本助手,擅长中文表达、总结与资料整理', + personality: '稳健细致,擅长长文阅读和中文语境下的结构化表达', + }, } as const; /** diff --git a/packages/shared/test/cat-configs.test.js b/packages/shared/test/cat-configs.test.js new file mode 100644 index 000000000..33a428b44 --- /dev/null +++ b/packages/shared/test/cat-configs.test.js @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CAT_CONFIGS } from '../dist/index.js'; + +test('CAT_CONFIGS exposes first-class kimi fallback cat', () => { + assert.equal(CAT_CONFIGS.kimi?.clientId, 'kimi'); + assert.equal(CAT_CONFIGS.kimi?.avatar, '/avatars/kimi.png'); + assert.equal(CAT_CONFIGS.kimi?.displayName, '梵花猫'); + assert.equal(CAT_CONFIGS.kimi?.breedId, 'moonshot'); +}); diff --git a/packages/web/public/avatars/kimi.png b/packages/web/public/avatars/kimi.png new file mode 100644 index 000000000..3eae19fbf Binary files /dev/null and b/packages/web/public/avatars/kimi.png differ diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 37ab78b27..e9cac40d5 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -48,6 +48,11 @@ --color-gemini-dark: #3a6fa5; --color-gemini-bg: #eaf4fb; + --color-kimi-primary: #4b5563; + --color-kimi-light: #e5e7eb; + --color-kimi-dark: #1f2937; + --color-kimi-bg: #f9fafb; + --color-dare-primary: #d4a76a; --color-dare-light: #e8c99b; --color-dare-dark: #8b6f47; @@ -186,6 +191,7 @@ --color-opus-bg: rgba(155, 126, 189, 0.15); --color-codex-bg: rgba(91, 140, 90, 0.15); --color-gemini-bg: rgba(91, 155, 213, 0.15); + --color-kimi-bg: rgba(75, 85, 99, 0.18); --color-dare-bg: rgba(212, 167, 106, 0.15); /* Connector dark mode overrides — muted translucent backgrounds, lighter text */ diff --git a/packages/web/src/components/AuthorizationCard.tsx b/packages/web/src/components/AuthorizationCard.tsx index 91ad669e2..f49932dcd 100644 --- a/packages/web/src/components/AuthorizationCard.tsx +++ b/packages/web/src/components/AuthorizationCard.tsx @@ -7,6 +7,7 @@ const CAT_LABELS: Record = { opus: '布偶猫', codex: '缅因猫', gemini: '暹罗猫', + kimi: '梵花猫', dare: '狸花猫', }; diff --git a/packages/web/src/components/HubMemberOverviewCard.tsx b/packages/web/src/components/HubMemberOverviewCard.tsx index 7a4f4107a..0ff4869cc 100644 --- a/packages/web/src/components/HubMemberOverviewCard.tsx +++ b/packages/web/src/components/HubMemberOverviewCard.tsx @@ -23,6 +23,7 @@ function clientRuntimeLabel(cat: CatData, configCat?: CatConfig) { if (accountRef.includes('claude')) return 'Claude'; if (accountRef.includes('codex')) return 'Codex'; if (accountRef.includes('gemini')) return 'Gemini'; + if (accountRef.includes('kimi') || accountRef.includes('moonshot')) return 'Kimi'; if (accountRef.includes('opencode')) return 'OpenCode'; if (accountRef.includes('dare')) return 'Dare'; if (cat.clientId === 'antigravity') return 'Antigravity'; @@ -37,12 +38,13 @@ function accountSummary(cat: CatData) { accountRef === 'claude' || accountRef === 'codex' || accountRef === 'gemini' || + accountRef === 'kimi' || accountRef === 'dare' || accountRef === 'opencode' ) { - return '内置 OAuth 账号'; + return 'CLI(内置)账号'; } - return `API Key · ${accountRef}`; + return `CLI(配置) · ${accountRef}`; } function getMetaSummary(cat: CatData, configCat?: CatConfig) { @@ -134,7 +136,7 @@ export function HubCoCreatorOverviewCard({ coCreator, onEdit }: { coCreator: CoC export function HubOverviewToolbar({ onAddMember }: { onAddMember?: () => void }) { return (
-

全部 · 订阅 · API Key · 未启用

+

全部 · CLI(内置) · CLI(配置) · 未启用