diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d7..1b83f28a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Added (CLI) +- **Kimi Code CLI provider.** CodeBurn now reads Kimi session usage from + `$KIMI_SHARE_DIR/sessions/` or `~/.kimi/sessions/`, including subagent + `wire.jsonl` files. The parser consumes Kimi's official `StatusUpdate` + token usage fields (`input_other`, `input_cache_read`, + `input_cache_creation`, `output`), normalizes Kimi tool names such as + `Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code + model aliases to priced Kimi K2 entries. + ## 0.9.8 - 2026-05-10 ### Added (CLI) diff --git a/README.md b/README.md index b3700224..e9f5af92 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr | | Roo Code | Yes | [roo-code.md](docs/providers/roo-code.md) | | | KiloCode | Yes | [kilo-code.md](docs/providers/kilo-code.md) | | | Qwen | Yes | [qwen.md](docs/providers/qwen.md) | +| | Kimi Code CLI | Yes | [kimi.md](docs/providers/kimi.md) | | | Goose | Yes | [goose.md](docs/providers/goose.md) | | | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) | | | Crush | Yes | [crush.md](docs/providers/crush.md) | @@ -380,7 +381,9 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta **Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. -CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn. +**Kimi Code CLI** stores session logs under `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///`. CodeBurn reads `wire.jsonl` `StatusUpdate.token_usage` records, maps `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns, and includes subagent sessions under each session's `subagents/` folder. + +CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by session+message ID for Kimi), filters by date range per entry, and classifies each turn. ## Environment Variables @@ -390,6 +393,8 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke | `CLAUDE_CONFIG_DIRS` | OS-delimited list of Claude data directories to scan together (e.g. `~/.claude-work:~/.claude-personal`). Sessions merge into one row per project. Overrides `CLAUDE_CONFIG_DIR` when set. | | `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) | | `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) | +| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) | +| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model | | `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) | ## Sponsoring CodeBurn diff --git a/assets/providers/kimi.svg b/assets/providers/kimi.svg new file mode 100644 index 00000000..c09b36fe --- /dev/null +++ b/assets/providers/kimi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/architecture.md b/docs/architecture.md index 9b1ea14f..c7f1a4a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,9 +128,9 @@ type Provider = { } ``` -`src/providers/index.ts` registers eighteen providers across two tiers: +`src/providers/index.ts` registers nineteen providers across two tiers: -- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. +- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `kimi`, `roo-code`. Imported at module load. - **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed. Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run. @@ -181,7 +181,7 @@ The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish - `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans. - `tests/security/` (1 file) covers prototype-pollution guards. -- `tests/providers/` (14 files) covers per-provider parsing. +- `tests/providers/` (15 files) covers per-provider parsing. - `tests/fixtures/` holds redacted real-world session data. Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue. diff --git a/docs/providers/README.md b/docs/providers/README.md index 05f43dbf..1f3c03f9 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -17,6 +17,7 @@ For the architectural picture, see `../architecture.md`. | [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | | [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` | | [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` | +| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` | | [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` | | [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` | | [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` | diff --git a/docs/providers/kimi.md b/docs/providers/kimi.md new file mode 100644 index 00000000..19d6876e --- /dev/null +++ b/docs/providers/kimi.md @@ -0,0 +1,62 @@ +# Kimi + +Kimi Code CLI session parser. + +- **Source:** `src/providers/kimi.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/kimi.test.ts` + +## Where it reads from + +`$KIMI_SHARE_DIR/sessions/` if set, otherwise `~/.kimi/sessions/`. + +Kimi stores sessions by work-directory hash: + +```text +~/.kimi/ + kimi.json + config.toml + sessions/ + / + / + context.jsonl + wire.jsonl + state.json + subagents/ + / + context.jsonl + wire.jsonl +``` + +`kimi.json` maps each work-directory hash back to the original working path. CodeBurn uses that to display the project basename; if the metadata file is missing, the hash directory name is used. + +## Storage Format + +CodeBurn reads `wire.jsonl`. Each data line is a persisted wire record: + +```json +{"timestamp":1776162403,"message":{"type":"StatusUpdate","payload":{"message_id":"msg-1","token_usage":{"input_other":100,"input_cache_read":25,"input_cache_creation":10,"output":40}}}} +``` + +`TurnBegin` / `SteerInput` provide the user prompt, `ToolCall` / `ToolCallRequest` provide tool names and shell commands, and `StatusUpdate.token_usage` provides the billable token counts. + +## Caching + +None. + +## Deduplication + +Per `kimi::`, falling back to the status-update line index if the message id is absent. + +## Quirks + +- Kimi's official `TokenUsage` separates `input_other`, `input_cache_read`, `input_cache_creation`, and `output`. CodeBurn maps those directly into input, cache read, cache write, and output. +- The current Kimi wire schema does not persist the model on every usage update. CodeBurn uses `KIMI_MODEL_NAME` when set, then the active `~/.kimi/config.toml` default model, then `kimi-auto`. +- `kimi-auto`, `kimi-code`, and `kimi-for-coding` are priced as `kimi-k2-thinking` so managed Kimi Code sessions do not show as `$0` when the exact backend model is hidden. +- Subagent sessions are discovered from `subagents//wire.jsonl` and parsed as separate Kimi sessions under the same project. + +## When Fixing A Bug Here + +1. Reproduce with a tiny `wire.jsonl` fixture in `tests/providers/kimi.test.ts`. +2. If token totals look wrong, inspect `StatusUpdate.token_usage` first; `context.jsonl` only stores context checkpoints and cumulative counts, not per-step billing detail. +3. If tools are missing, check whether Kimi emitted `ToolCall`, `ToolCallRequest`, or nested `SubagentEvent`; CodeBurn intentionally counts subagent wire files separately to avoid double-counting parent mirrors. diff --git a/gnome/indicator.js b/gnome/indicator.js index c2f8266e..533f6441 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -41,6 +41,7 @@ const PROVIDERS = [ { id: 'gemini', label: 'Gemini' }, { id: 'kilo-code', label: 'Kilo Code' }, { id: 'kiro', label: 'Kiro' }, + { id: 'kimi', label: 'Kimi' }, { id: 'roo-code', label: 'Roo Code' }, ]; @@ -69,6 +70,7 @@ const PROVIDER_PATHS = { codex: '.codex/sessions', cursor: '.config/Cursor/User/globalStorage/state.vscdb', copilot: '.copilot/session-state', + kimi: '.kimi/sessions', pi: '.pi/agent/sessions', }; diff --git a/gnome/prefs.js b/gnome/prefs.js index 2b9d477b..08d4b824 100644 --- a/gnome/prefs.js +++ b/gnome/prefs.js @@ -13,6 +13,7 @@ const PROVIDERS = [ { id: 'goose', label: 'Goose' }, { id: 'kilo-code', label: 'Kilo Code' }, { id: 'kiro', label: 'Kiro' }, + { id: 'kimi', label: 'Kimi' }, { id: 'openclaw', label: 'OpenClaw' }, { id: 'opencode', label: 'OpenCode' }, { id: 'pi', label: 'Pi' }, diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 00b27e8b..9e238cea 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -726,6 +726,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case droid = "Droid" case gemini = "Gemini" case kiro = "Kiro" + case kimi = "Kimi" case kiloCode = "KiloCode" case openclaw = "OpenClaw" case opencode = "OpenCode" @@ -758,6 +759,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .gemini: "gemini" case .kiloCode: "kilo-code" case .kiro: "kiro" + case .kimi: "kimi" case .openclaw: "openclaw" case .opencode: "opencode" case .pi: "pi" diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 6561cc97..b5f1570a 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -347,6 +347,7 @@ extension ProviderFilter { case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) + case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0) case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0) diff --git a/package.json b/package.json index a58098db..301f9f64 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "claude-code", "cursor", "codex", + "kimi", "opencode", "pi", "ai-coding", diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b46dbcce..ac3eb349 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -54,6 +54,7 @@ const PROVIDER_COLORS: Record = { cursor: '#00B4D8', opencode: '#A78BFA', pi: '#F472B6', + kimi: '#B6E34A', all: '#FF8C42', } @@ -515,6 +516,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { cursor: 'Cursor', opencode: 'OpenCode', pi: 'Pi', + kimi: 'Kimi', } function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } diff --git a/src/models.ts b/src/models.ts index e4441e0a..a40fb59a 100644 --- a/src/models.ts +++ b/src/models.ts @@ -170,6 +170,9 @@ const BUILTIN_ALIASES: Record = { 'cline-auto': 'claude-sonnet-4-5', 'openclaw-auto': 'claude-sonnet-4-5', 'qwen-auto': 'claude-sonnet-4-5', + 'kimi-auto': 'kimi-k2-thinking', + 'kimi-code': 'kimi-k2-thinking', + 'kimi-for-coding': 'kimi-k2-thinking', // Cursor emits dot-version tier-last names plus tier/reasoning suffixes // that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`, // `-high-thinking`, `-fast-mode`). Missing aliases here surface as $0 in @@ -355,6 +358,7 @@ const autoModelNames: Record = { 'cline-auto': 'Cline (auto)', 'openclaw-auto': 'OpenClaw (auto)', 'qwen-auto': 'Qwen (auto)', + 'kimi-auto': 'Kimi (auto)', } const SHORT_NAMES: Record = { @@ -398,6 +402,17 @@ const SHORT_NAMES: Record = { 'gemini-3-flash-preview': 'Gemini 3 Flash', 'gemini-2.5-pro': 'Gemini 2.5 Pro', 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo', + 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'kimi-thinking-preview': 'Kimi Thinking', + 'kimi-k2.6': 'Kimi K2.6', + 'kimi-k2.5': 'Kimi K2.5', + 'kimi-k2p5': 'Kimi K2.5', + 'kimi-k2-instruct': 'Kimi K2 Instruct', + 'kimi-k2-0905': 'Kimi K2', + 'kimi-k2': 'Kimi K2', + 'kimi-latest': 'Kimi Latest', + 'moonshot-v1': 'Moonshot v1', 'deepseek-coder-max': 'DeepSeek Coder Max', 'deepseek-coder': 'DeepSeek Coder', 'deepseek-r1': 'DeepSeek R1', diff --git a/src/providers/index.ts b/src/providers/index.ts index 38ed4902..f35b4c57 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,7 @@ import { droid } from './droid.js' import { gemini } from './gemini.js' import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' +import { kimi } from './kimi.js' import { openclaw } from './openclaw.js' import { pi, omp } from './pi.js' import { qwen } from './qwen.js' @@ -101,7 +102,7 @@ async function loadCrush(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, kimi, rooCode] export async function getAllProviders(): Promise { const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()]) diff --git a/src/providers/kimi.ts b/src/providers/kimi.ts new file mode 100644 index 00000000..75242cc8 --- /dev/null +++ b/src/providers/kimi.ts @@ -0,0 +1,394 @@ +import { createHash } from 'crypto' +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { extractBashCommands } from '../bash-utils.js' +import { readSessionLines } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +type JsonObject = Record + +const toolNameMap: Record = { + Shell: 'Bash', + Bash: 'Bash', + bash: 'Bash', + ReadFile: 'Read', + ReadMediaFile: 'Read', + WriteFile: 'Write', + StrReplaceFile: 'Edit', + Grep: 'Grep', + Glob: 'Glob', + SearchWeb: 'WebSearch', + FetchURL: 'WebFetch', + Agent: 'Agent', + AgentTool: 'Agent', + TaskList: 'Agent', + TaskOutput: 'Agent', + TaskStop: 'Agent', + AskUserQuestion: 'AskUser', + SetTodoList: 'TodoWrite', + Think: 'Think', + EnterPlanMode: 'EnterPlanMode', + ExitPlanMode: 'ExitPlanMode', + SendDMail: 'DMail', +} + +function asObject(value: unknown): JsonObject | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : null +} + +function stringField(obj: JsonObject | null, key: string): string | undefined { + const value = obj?.[key] + return typeof value === 'string' ? value : undefined +} + +function numericField(obj: JsonObject, ...keys: string[]): number { + for (const key of keys) { + const raw = obj[key] + const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN + if (Number.isFinite(n) && n > 0) return Math.trunc(n) + } + return 0 +} + +function getShareDir(overrideDir?: string): string { + return overrideDir ?? process.env['KIMI_SHARE_DIR'] ?? join(homedir(), '.kimi') +} + +function md5(text: string): string { + return createHash('md5').update(text, 'utf-8').digest('hex') +} + +function projectNameFromPath(pathValue: string): string { + const cleaned = pathValue.replace(/\/+$/, '') + return basename(cleaned) || cleaned || 'kimi' +} + +async function loadProjectNames(shareDir: string): Promise> { + const projects = new Map() + const raw = await readFile(join(shareDir, 'kimi.json'), 'utf-8').catch(() => null) + if (!raw) return projects + + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return projects + } + + const workDirs = asObject(data)?.['work_dirs'] + if (!Array.isArray(workDirs)) return projects + + for (const entry of workDirs) { + const obj = asObject(entry) + const pathValue = stringField(obj, 'path') + if (!pathValue) continue + const hash = md5(pathValue) + const project = projectNameFromPath(pathValue) + projects.set(hash, project) + + const kaos = stringField(obj, 'kaos') + if (kaos && kaos !== 'local') projects.set(`${kaos}_${hash}`, project) + } + + return projects +} + +function parseTomlString(raw: string): string | null { + const value = raw.trim() + if (!value) return null + if (value.startsWith('"')) { + const match = value.match(/^"((?:[^"\\]|\\.)*)"/) + if (!match) return null + try { + return JSON.parse(`"${match[1]}"`) as string + } catch { + return match[1] ?? null + } + } + if (value.startsWith("'")) { + const match = value.match(/^'([^']*)'/) + return match?.[1] ?? null + } + const match = value.match(/^([^#\s]+)/) + return match?.[1] ?? null +} + +function parseDefaultModelKey(configToml: string): string | null { + for (const line of configToml.split('\n')) { + const match = line.match(/^\s*default_model\s*=\s*(.+)$/) + if (!match) continue + return parseTomlString(match[1]!) + } + return null +} + +function parseModelSectionName(line: string): string | null { + const match = line.trim().match(/^\[models\.(?:"([^"]+)"|'([^']+)'|([^\]]+))\]$/) + if (!match) return null + return (match[1] ?? match[2] ?? match[3] ?? '').trim() || null +} + +function parseModelIdForKey(configToml: string, modelKey: string): string | null { + let inSection = false + for (const line of configToml.split('\n')) { + const section = parseModelSectionName(line) + if (section !== null) { + inSection = section === modelKey + continue + } + if (!inSection) continue + if (/^\s*\[/.test(line)) { + inSection = false + continue + } + const match = line.match(/^\s*model\s*=\s*(.+)$/) + if (!match) continue + return parseTomlString(match[1]!) + } + return null +} + +async function getConfiguredModel(shareDir: string): Promise { + const envModel = process.env['KIMI_MODEL_NAME']?.trim() + if (envModel) return envModel + + const raw = await readFile(join(shareDir, 'config.toml'), 'utf-8').catch(() => null) + if (!raw) return 'kimi-auto' + + const defaultModel = parseDefaultModelKey(raw) + if (!defaultModel) return 'kimi-auto' + + return parseModelIdForKey(raw, defaultModel) ?? defaultModel +} + +function parseJsonObject(text: string | undefined): JsonObject | null { + if (!text) return null + try { + return asObject(JSON.parse(text)) + } catch { + return null + } +} + +function extractUserText(value: unknown): string { + if (typeof value === 'string') return value.slice(0, 500) + if (!Array.isArray(value)) return '' + + return value + .map(part => stringField(asObject(part), 'text') ?? '') + .filter(Boolean) + .join(' ') + .slice(0, 500) +} + +function timestampToIso(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + + const millis = value > 1_000_000_000_000 ? value : value * 1000 + const date = new Date(millis) + return Number.isFinite(date.getTime()) ? date.toISOString() : '' +} + +function extractEnvelope(record: JsonObject): { type: string; payload: JsonObject; timestamp: string } | null { + const message = asObject(record['message']) + const envelope = message ?? record + const type = stringField(envelope, 'type') + const payload = asObject(envelope['payload']) + if (!type || !payload) return null + return { type, payload, timestamp: timestampToIso(record['timestamp']) } +} + +function extractUsage(payload: JsonObject): { + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number +} | null { + const usage = asObject(payload['token_usage']) ?? asObject(payload['usage']) + if (!usage) return null + + const cacheReadInputTokens = numericField(usage, 'input_cache_read', 'cache_read_input_tokens', 'cached_input_tokens') + const cacheCreationInputTokens = numericField(usage, 'input_cache_creation', 'cache_creation_input_tokens') + let inputTokens = numericField(usage, 'input_other', 'input_tokens') + if (inputTokens === 0) { + const totalInput = numericField(usage, 'input') + inputTokens = Math.max(0, totalInput - cacheReadInputTokens - cacheCreationInputTokens) + } + const outputTokens = numericField(usage, 'output', 'output_tokens') + + if (inputTokens === 0 && outputTokens === 0 && cacheReadInputTokens === 0 && cacheCreationInputTokens === 0) { + return null + } + + return { inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens } +} + +function extractTool(payload: JsonObject): { tool: string; bashCommands: string[] } | null { + const fn = asObject(payload['function']) + const rawName = stringField(fn, 'name') ?? stringField(payload, 'name') + if (!rawName) return null + + const tool = toolNameMap[rawName] ?? rawName + const argsText = stringField(fn, 'arguments') ?? stringField(payload, 'arguments') + const args = parseJsonObject(argsText) + const command = stringField(args, 'command') + const bashCommands = tool === 'Bash' && command ? extractBashCommands(command) : [] + + return { tool, bashCommands } +} + +function createParser(source: SessionSource, shareDir: string, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const configuredModel = await getConfiguredModel(shareDir) + const tools = new Set() + const bashCommands = new Set() + let currentUserMessage = '' + const sessionId = basename(dirname(source.path)) + let index = 0 + + for await (const line of readSessionLines(source.path)) { + if (!line.trim()) continue + + let record: JsonObject | null = null + try { + record = asObject(JSON.parse(line)) + } catch { + continue + } + if (!record) continue + + const envelope = extractEnvelope(record) + if (!envelope || envelope.type === 'metadata') continue + + if (envelope.type === 'TurnBegin' || envelope.type === 'SteerInput') { + currentUserMessage = extractUserText(envelope.payload['user_input']) + continue + } + + if (envelope.type === 'TurnEnd') { + currentUserMessage = '' + tools.clear() + bashCommands.clear() + continue + } + + if (envelope.type === 'ToolCall' || envelope.type === 'ToolCallRequest') { + const extracted = extractTool(envelope.payload) + if (!extracted) continue + tools.add(extracted.tool) + for (const command of extracted.bashCommands) bashCommands.add(command) + continue + } + + if (envelope.type !== 'StatusUpdate') continue + + const usage = extractUsage(envelope.payload) + if (!usage) continue + + const rawMessageId = stringField(envelope.payload, 'message_id') + const dedupKey = `kimi:${sessionId}:${rawMessageId ?? index}` + index++ + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = stringField(envelope.payload, 'model') ?? stringField(envelope.payload, 'model_name') ?? configuredModel + const costUSD = calculateCost( + model, + usage.inputTokens, + usage.outputTokens, + usage.cacheCreationInputTokens, + usage.cacheReadInputTokens, + 0, + ) + + yield { + provider: 'kimi', + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...tools], + bashCommands: [...bashCommands], + timestamp: envelope.timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: currentUserMessage, + sessionId, + } + + tools.clear() + bashCommands.clear() + } + }, + } +} + +async function addWireSource(sources: SessionSource[], filePath: string, project: string): Promise { + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) return + sources.push({ path: filePath, project, provider: 'kimi' }) +} + +export function createKimiProvider(overrideDir?: string): Provider { + const shareDir = getShareDir(overrideDir) + + return { + name: 'kimi', + displayName: 'Kimi', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + const sources: SessionSource[] = [] + const sessionsRoot = join(shareDir, 'sessions') + const projectNames = await loadProjectNames(shareDir) + const workDirs = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []) + + for (const workDir of workDirs) { + if (!workDir.isDirectory()) continue + + const project = projectNames.get(workDir.name) ?? workDir.name + const workDirPath = join(sessionsRoot, workDir.name) + const sessionDirs = await readdir(workDirPath, { withFileTypes: true }).catch(() => []) + + for (const sessionDir of sessionDirs) { + if (!sessionDir.isDirectory()) continue + + const sessionPath = join(workDirPath, sessionDir.name) + await addWireSource(sources, join(sessionPath, 'wire.jsonl'), project) + + const subagentsPath = join(sessionPath, 'subagents') + const subagents = await readdir(subagentsPath, { withFileTypes: true }).catch(() => []) + for (const subagent of subagents) { + if (!subagent.isDirectory()) continue + await addWireSource(sources, join(subagentsPath, subagent.name, 'wire.jsonl'), project) + } + } + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, shareDir, seenKeys) + }, + } +} + +export const kimi = createKimiProvider() diff --git a/tests/models-hoist.test.ts b/tests/models-hoist.test.ts index 13af3e57..324e6ff6 100644 --- a/tests/models-hoist.test.ts +++ b/tests/models-hoist.test.ts @@ -50,6 +50,10 @@ const KNOWN_NAMES = [ 'kiro-auto', 'cline-auto', 'qwen-auto', + 'kimi-auto', + 'kimi-for-coding', + 'kimi-k2-thinking-turbo', + 'kimi-k2.6', 'o3', 'o4-mini', 'deepseek-coder', @@ -86,6 +90,14 @@ describe('post-hoist resolution stability', () => { expect(getShortModelName('claude-3-5-haiku')).toBe('Haiku 3.5') }) + it('kimi managed aliases resolve to priced Kimi models', () => { + expect(getShortModelName('kimi-auto')).toBe('Kimi (auto)') + expect(getShortModelName('kimi-for-coding')).toBe('Kimi K2 Thinking') + expect(getShortModelName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo') + expect(getShortModelName('kimi-k2.6')).toBe('Kimi K2.6') + expect(getModelCosts('kimi-auto')?.inputCostPerToken).toBeGreaterThan(0) + }) + it('getModelCosts returns positive token costs for every known name', () => { for (const name of KNOWN_NAMES) { const c = getModelCosts(name) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 4497946f..30d9995e 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'kimi', 'roo-code']) }) it('includes sqlite providers after async load', async () => { @@ -60,6 +60,14 @@ describe('provider registry', () => { expect(claude.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') }) + it('kimi model and tool display names are normalized', () => { + const kimi = providers.find(p => p.name === 'kimi')! + expect(kimi.modelDisplayName('kimi-auto')).toBe('Kimi (auto)') + expect(kimi.modelDisplayName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo') + expect(kimi.toolDisplayName('Shell')).toBe('Bash') + expect(kimi.toolDisplayName('WriteFile')).toBe('Write') + }) + it('cursor model display names handle auto mode', async () => { const all = await getAllProviders() const cursor = all.find(p => p.name === 'cursor')! diff --git a/tests/providers/kimi.test.ts b/tests/providers/kimi.test.ts new file mode 100644 index 00000000..486a03ee --- /dev/null +++ b/tests/providers/kimi.test.ts @@ -0,0 +1,192 @@ +import { createHash } from 'crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createKimiProvider } from '../../src/providers/kimi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kimi-test-')) +}) + +afterEach(async () => { + delete process.env.KIMI_MODEL_NAME + await rm(tmpDir, { recursive: true, force: true }) +}) + +function md5(value: string): string { + return createHash('md5').update(value, 'utf-8').digest('hex') +} + +function record(timestamp: number, type: string, payload: Record): string { + return JSON.stringify({ + timestamp, + message: { type, payload }, + }) +} + +async function writeSession(workDir: string, sessionId: string, lines: string[]): Promise { + const hash = md5(workDir) + const sessionDir = join(tmpDir, 'sessions', hash, sessionId) + await mkdir(sessionDir, { recursive: true }) + const wirePath = join(sessionDir, 'wire.jsonl') + await writeFile(wirePath, [ + JSON.stringify({ type: 'metadata', protocol_version: '2' }), + ...lines, + ].join('\n') + '\n') + return wirePath +} + +async function collect(provider: ReturnType, path: string, seen = new Set()): Promise { + const parser = provider.createSessionParser({ path, project: 'app', provider: 'kimi' }, seen) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('Kimi provider', () => { + it('discovers session and subagent wire logs under KIMI_SHARE_DIR layout', async () => { + const workDir = '/Users/test/work/app' + const hash = md5(workDir) + await writeFile(join(tmpDir, 'kimi.json'), JSON.stringify({ + work_dirs: [{ path: workDir, kaos: 'local', last_session_id: 'sess-1' }], + })) + + const sessionDir = join(tmpDir, 'sessions', hash, 'sess-1') + const subagentDir = join(sessionDir, 'subagents', 'agent-1') + await mkdir(subagentDir, { recursive: true }) + await writeFile(join(sessionDir, 'wire.jsonl'), '\n') + await writeFile(join(subagentDir, 'wire.jsonl'), '\n') + + const sources = await createKimiProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.map(s => s.project)).toEqual(['app', 'app']) + expect(sources.map(s => s.provider)).toEqual(['kimi', 'kimi']) + expect(sources.map(s => s.path).sort()).toEqual([ + join(sessionDir, 'subagents', 'agent-1', 'wire.jsonl'), + join(sessionDir, 'wire.jsonl'), + ].sort()) + }) + + it('parses Kimi wire StatusUpdate usage, tools, bash commands, and configured model', async () => { + await writeFile(join(tmpDir, 'config.toml'), [ + 'default_model = "kimi-code/k2"', + '', + '[models."kimi-code/k2"]', + 'model = "kimi-k2-thinking-turbo"', + ].join('\n')) + + const wirePath = await writeSession('/Users/test/work/app', 'sess-1', [ + record(1776162400, 'TurnBegin', { user_input: 'add status endpoint' }), + record(1776162401, 'ToolCall', { + type: 'function', + id: 'call-shell', + function: { name: 'Shell', arguments: JSON.stringify({ command: 'git status && npm test' }) }, + }), + record(1776162402, 'ToolCall', { + type: 'function', + id: 'call-read', + function: { name: 'ReadFile', arguments: JSON.stringify({ path: 'src/index.ts' }) }, + }), + record(1776162403, 'StatusUpdate', { + message_id: 'msg-1', + token_usage: { + input_other: 100, + input_cache_read: 25, + input_cache_creation: 10, + output: 40, + }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'kimi', + model: 'kimi-k2-thinking-turbo', + inputTokens: 100, + outputTokens: 40, + cacheReadInputTokens: 25, + cacheCreationInputTokens: 10, + cachedInputTokens: 25, + tools: ['Bash', 'Read'], + bashCommands: ['git', 'npm'], + timestamp: '2026-04-14T10:26:43.000Z', + deduplicationKey: 'kimi:sess-1:msg-1', + userMessage: 'add status endpoint', + sessionId: 'sess-1', + }) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('uses content parts, model payload overrides, and message-id deduplication', async () => { + process.env.KIMI_MODEL_NAME = 'kimi-k2-thinking' + const wirePath = await writeSession('/Users/test/work/app', 'sess-2', [ + record(1776023300, 'TurnBegin', { + user_input: [ + { type: 'text', text: 'refactor parser' }, + { type: 'image_url', image_url: { url: 'file://diagram.png' } }, + { type: 'text', text: 'carefully' }, + ], + }), + record(1776023301, 'ToolCallRequest', { + id: 'call-write', + name: 'WriteFile', + arguments: JSON.stringify({ path: 'src/parser.ts', content: 'x' }), + }), + record(1776023302, 'StatusUpdate', { + message_id: 'msg-2', + model_name: 'kimi-k2.6', + token_usage: { input_other: 5, output: 7 }, + }), + record(1776023303, 'StatusUpdate', { + message_id: 'msg-2', + model_name: 'kimi-k2.6', + token_usage: { input_other: 5, output: 7 }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'kimi-k2.6', + userMessage: 'refactor parser carefully', + tools: ['Write'], + deduplicationKey: 'kimi:sess-2:msg-2', + }) + }) + + it('skips non-usage updates and supports legacy input total fields defensively', async () => { + const wirePath = await writeSession('/Users/test/work/app', 'sess-3', [ + record(1776023400, 'TurnBegin', { user_input: 'summarize' }), + record(1776023401, 'StatusUpdate', { context_usage: 0.5 }), + record(1776023402, 'StatusUpdate', { + message_id: 'msg-3', + token_usage: { + input: 120, + input_cache_read: 30, + input_cache_creation: 10, + output_tokens: 20, + }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 80, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 10, + outputTokens: 20, + model: 'kimi-auto', + }) + }) +})