diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md index 8030088..a92356b 100644 --- a/docs/providers/kimicode.md +++ b/docs/providers/kimicode.md @@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity. ## Where it reads from -The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans: +By default the provider scans every known Kimi Code runtime store: ```text -$KIMI_CODE_HOME/sessions/wd_*/session_*/ +~/.kimi-code +~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime) +``` + +Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans: + +```text +$HOME/sessions/wd_*// ├── state.json └── agents//wire.jsonl ``` +Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions. + Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions. ## Storage format diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 8e34638..40deb71 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -494,6 +494,7 @@ extension ProviderFilter { 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 .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0) case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/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) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 1d9b6d2..1b68a22 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -83,6 +83,7 @@ const PROVIDER_COLORS: Record = { opencode: '#A78BFA', pi: '#F472B6', kimi: '#B6E34A', + kimicode: '#A3E635', all: '#FF8C42', } @@ -670,6 +671,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { opencode: 'OpenCode', pi: 'Pi', kimi: 'Kimi', + kimicode: 'Kimi Code', } function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 2066572..7435426 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -84,6 +84,7 @@ export type ProviderCost = { import type { OptimizeResult } from './optimize.js' import { getCurrency } from './currency.js' import type { GranularHistory } from './granular-history.js' +import { getShortModelName } from './models.js' import type { ReworkedFile } from './workflow-insights.js' import type { PrRow, BranchRow } from './sessions-report.js' @@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa } function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] { - return models - .filter(m => m.name !== SYNTHETIC_MODEL_NAME) + // Day entries key models by the raw provider id (day-aggregator), so resolve + // display names here — the menubar shows "Kimi K3" rather than "k3". Ids that + // collapse to one display name (e.g. k3 and kimi-k3) merge into a single row. + const merged = new Map() + for (const m of models) { + if (m.name === SYNTHETIC_MODEL_NAME) continue + const name = getShortModelName(m.name) + const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 } + acc.cost += m.cost + acc.calls += m.calls + acc.savingsUSD += m.savingsUSD ?? 0 + acc.estimatedCostUSD += m.estimatedCostUSD ?? 0 + merged.set(name, acc) + } + return [...merged.entries()] + .sort(([, a], [, b]) => b.cost - a.cost) .slice(0, TOP_MODELS_LIMIT) - .map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 })) + .map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD })) } function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { diff --git a/src/models.ts b/src/models.ts index 20d3091..6e05785 100644 --- a/src/models.ts +++ b/src/models.ts @@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record = { 'kimi-auto': 'kimi-k2-thinking', 'kimi-code': 'kimi-k2-thinking', 'kimi-for-coding': 'kimi-k2-thinking', + // Kimi Code wires report the bare `k3` id in llm.request.model; without an + // alias those calls priced at $0 and the provider looked absent in the UI. + 'k3': 'kimi-k3', + // Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`. + 'k3-agent': 'kimi-k3', + 'k2d6-agent': 'kimi-k2p6', 'mimo-v2-flash': 'xiaomi/mimo-v2-flash', 'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro', // Cursor emits dot-version tier-last names plus tier/reasoning suffixes @@ -879,6 +885,8 @@ const SHORT_NAMES: Record = { 'gemini-2.5-flash': 'Gemini 2.5 Flash', 'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo', 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'kimi-k3': 'Kimi K3', + 'kimi-k2p6': 'Kimi K2.6', 'kimi-thinking-preview': 'Kimi Thinking', 'kimi-k2.6': 'Kimi K2.6', 'kimi-k2.5': 'Kimi K2.5', diff --git a/src/providers/kimicode.ts b/src/providers/kimicode.ts index e843ae1..b85845a 100644 --- a/src/providers/kimicode.ts +++ b/src/providers/kimicode.ts @@ -71,8 +71,18 @@ function timestampIso(value: unknown): string { return Number.isNaN(date.getTime()) ? '' : date.toISOString() } -function kimicodeHome(override?: string): string { - return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code')) +function kimicodeHomes(override?: string): string[] { + const explicit = override || process.env['KIMI_CODE_HOME'] + if (explicit) return [resolve(explicit)] + // Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep + // the same wire layout under their own home (Kimi desktop app, Kimi Code + // IDE); each home is scanned so embedded-agent usage is not invisible. + const home = homedir() + const homes = [ + join(home, '.kimi-code'), + join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'), + ] + return [...new Set(homes.map(h => resolve(h)))] } async function directoryEntries(path: string) { @@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise { const workDirPath = join(sessionsDir, workDirEntry.name) for (const sessionEntry of await directoryEntries(workDirPath)) { - if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue + // Session dir naming differs by host product: the CLI uses session_*, + // embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory + // is accepted; the agents/*/wire.jsonl probe below gates real sessions. + if (!sessionEntry.isDirectory()) continue const sessionDir = join(workDirPath, sessionEntry.name) const state = await readState(sessionDir) const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name) @@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider { }, async probeRoots(): Promise { - return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }] + return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' })) }, async discoverSessions(): Promise { - return discoverSources(kimicodeHome(homeOverride)) + const all: SessionSource[] = [] + for (const home of kimicodeHomes(homeOverride)) { + all.push(...await discoverSources(home)) + } + return all.sort((a, b) => a.path.localeCompare(b.path)) }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index fe9b55a..cd44741 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => { expect(payload.current.topModels[0].name).toBe('Model0') }) + it('resolves raw model ids to display names in topModels and merges rows that collapse', () => { + // Day entries key models by the raw wire id (day-aggregator); the menubar + // must show friendly names and merge ids that share one (k3 + kimi-k3). + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'k3', cost: 2.5, calls: 78 }, + { name: 'kimi-k3', cost: 0.5, calls: 2 }, + { name: 'kimi-for-coding', cost: 0.06, calls: 13 }, + { name: 'k3-agent', cost: 1.2, calls: 40 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')! + expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2) + expect(kimiK3.calls).toBe(78 + 2 + 40) + const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')! + expect(k2.cost).toBeCloseTo(0.06) + expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined() + expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined() + }) + it('caps topActivities at 20 so all task categories can surface', () => { const period: PeriodData = { label: 'Today', diff --git a/tests/menubar-savings.test.ts b/tests/menubar-savings.test.ts index ba416ab..bb60897 100644 --- a/tests/menubar-savings.test.ts +++ b/tests/menubar-savings.test.ts @@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => { const local = payload.current.topModels.find(m => m.name === 'Local Model')! expect(local.savingsUSD).toBe(5) expect(local.cost).toBe(0) - const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')! + const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')! expect(paid.savingsUSD).toBe(0) }) diff --git a/tests/models.test.ts b/tests/models.test.ts index 160c5f8..e0d3cfd 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -672,6 +672,9 @@ describe('observed provider model aliases', () => { const cases: Array<[string, string]> = [ ['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'], ['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'], + // Kimi Code wires report bare `k3` in llm.request.model; it must price + // through the kimi-k3 table entry, not fall through to $0. + ['k3', 'kimi-k3'], ] for (const [input, expectedModel] of cases) { @@ -685,6 +688,10 @@ describe('observed provider model aliases', () => { }) } + it('k3 shows the Kimi K3 display name', () => { + expect(getShortModelName('k3')).toBe('Kimi K3') + }) + it('does not map dated Qwen3 Max to a reseller price without provider context', () => { expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull() expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0) diff --git a/tests/providers/kimicode.test.ts b/tests/providers/kimicode.test.ts index 6bcc089..2ff426a 100644 --- a/tests/providers/kimicode.test.ts +++ b/tests/providers/kimicode.test.ts @@ -167,6 +167,40 @@ describe('Kimi Code provider', () => { expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true) }) + it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => { + // Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs + // conv-*/ctitle-* instead of session_*; their wires carry the same event + // format and must be discovered and priced the same way. + for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) { + const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main') + await mkdir(agentDir, { recursive: true }) + await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({ + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-01T10:05:00.000Z', + workDir: '/workspace/neutral-project', + })) + await writeFile(join(agentDir, 'wire.jsonl'), [ + JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }), + JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)), + JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)), + JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })), + '', + ].join('\n')) + } + + const provider = createKimicodeProvider(fixtureHome) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(2) + expect(sources.every(source => source.project === 'neutral-project')).toBe(true) + + const seen = new Set() + const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat() + expect(calls).toHaveLength(2) + expect(calls.every(call => call.model === 'k3-agent')).toBe(true) + // k3-agent prices through the kimi-k3 alias, never $0. + expect(calls.every(call => call.costUSD > 0)).toBe(true) + }) + it('parses single-turn usage with the real model id and estimated token pricing', async () => { const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [ prompt('Summarize the neutral module.', 1782900000000),