From 2b95aef7e56b6c4efb8c8b565038b02f0ff6fabc Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:52:37 +0300 Subject: [PATCH 1/2] feat(kimicode): Kimi Code CLI provider, parse ~/.kimi-code wire sessions (#747) Adds a provider for Kimi Code CLI (MoonshotAI/kimi-code, the successor of kimi-cli), reading ~/.kimi-code/sessions/wd_*/session_*/: state.json for session metadata and per-agent agents//wire.jsonl event streams. - Usage from usage.record events: inputOther->input, output->output, inputCacheRead->cacheRead, inputCacheCreation->cacheWrite. The store has no cost fields, so cost is computed from tokens and flagged estimated. - Real model attribution: usage.record.model carries the config ALIAS; the real model id lives on llm.request events. Rows resolve the alias through the alias->model map first, with the nearest preceding request as fallback, so aliases never leak into reports. - Subagent wire files parse as separate sources under one session without double counting; multi-turn continuations in one wire.jsonl are one session with multiple turns; retry-only failed sessions parse to zero usage; malformed JSONL lines are skipped. - Tool calls feed the tool breakdown; tool state resets at turn boundaries so a failed turn cannot bleed its tools into the next row. - KIMI_CODE_HOME override, eager registration, PROVIDER_ENV_VARS and PROVIDER_PARSE_VERSIONS entries, probeRoots for doctor, docs page. --- docs/providers/README.md | 1 + docs/providers/kimicode.md | 64 ++++++ src/providers/index.ts | 3 +- src/providers/kimicode.ts | 360 +++++++++++++++++++++++++++++++ src/session-cache.ts | 2 + tests/provider-registry.test.ts | 2 +- tests/providers/kimicode.test.ts | 347 +++++++++++++++++++++++++++++ 7 files changed, 777 insertions(+), 2 deletions(-) create mode 100644 docs/providers/kimicode.md create mode 100644 src/providers/kimicode.ts create mode 100644 tests/providers/kimicode.test.ts diff --git a/docs/providers/README.md b/docs/providers/README.md index 09446366..f4d2aa4c 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -23,6 +23,7 @@ For the architectural picture, see `../architecture.md`. | [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` | +| [Kimi Code](kimicode.md) | JSONL | `src/providers/kimicode.ts` | `tests/providers/kimicode.test.ts` | | [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` | | [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` | | [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` | diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md new file mode 100644 index 00000000..1e3202fb --- /dev/null +++ b/docs/providers/kimicode.md @@ -0,0 +1,64 @@ +# Kimi Code + +MoonshotAI Kimi Code local session usage and tool activity. + +- **Source:** `src/providers/kimicode.ts` +- **Loading:** eager +- **Test:** `tests/providers/kimicode.test.ts` + +## 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: + +```text +$KIMI_CODE_HOME/sessions/wd_*/session_*/ +├── state.json +└── agents//wire.jsonl +``` + +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 + +`wire.jsonl` contains one event per line. The parser uses: + +- `turn.prompt.input` for the current user message. +- `llm.request.model` for the real model ID and the turn prefix from `turnStep`. +- `context.append_loop_event.event` when its type is `tool.call`; the event's `name` feeds the tool breakdown. +- `usage.record.usage` for billed tokens. + +Token fields map as follows: + +| Kimi Code | CodeBurn | +|---|---| +| `inputOther` | input | +| `output` | output | +| `inputCacheRead` | cache read and cached input | +| `inputCacheCreation` | cache write | + +`usage.record.model` is only the configured alias. The parser resolves that alias through observed `llm.request` events to obtain the real model ID used for pricing and reports, falling back to the nearest preceding request when the alias is empty or unknown. Kimi Code records no cost, so CodeBurn computes it from the four token categories with `calculateCost` and marks it estimated. + +Malformed JSONL lines are skipped independently. A failed session containing retrying `llm.request` events but no `usage.record` events emits no calls and therefore contributes zero usage. Retry `attempt` values are not interpreted. + +## Caching + +Kimi Code is an eager provider using the shared session cache. Each agent's `wire.jsonl` is fingerprinted separately. `KIMI_CODE_HOME` and the Kimi Code parser version participate in the provider cache fingerprint. + +## Deduplication + +Usage calls use `kimicode::::`. Including the agent ID keeps main-agent and subagent events distinct, while stable line positions prevent a wire from being counted twice across parses. + +This agent-and-position-scoped key relies on the store invariant that each agent's `usage.record` events appear only in its own `wire.jsonl`, as identified by the `state.json` agents map. If a future Kimi Code version mirrors subagent usage into the main wire, the key must become content-scoped. + +## Quirks + +- Multi-turn continuations append to the same wire. The numeric prefix of `llm.request.turnStep` groups multiple model steps into the correct turn. +- Tools are attached to the next `usage.record` in the same wire and then cleared, so each `tool.call` contributes once. +- Kimi Code is the successor to the legacy `kimi-cli`. Kimi Code migrates predecessor configuration and sessions into its own store; this provider intentionally does not parse the old `~/.kimi` layout. + +## When fixing a bug here + +1. Keep real-model attribution based on `llm.request.model`; never report the alias from `usage.record.model`. +2. Test main and subagent wires together with a shared deduplication set. +3. Preserve malformed-line and retry-only coverage when adding event variants. +4. Keep fixtures sanitized and rooted in a temporary `KIMI_CODE_HOME`. diff --git a/src/providers/index.ts b/src/providers/index.ts index ad4c766f..07dc3903 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -12,6 +12,7 @@ import { ibmBob } from './ibm-bob.js' import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' import { kimi } from './kimi.js' +import { kimicode } from './kimicode.js' import { lingtaiTui } from './lingtai-tui.js' import { mistralVibe } from './mistral-vibe.js' import { mux } from './mux.js' @@ -189,7 +190,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/src/providers/kimicode.ts b/src/providers/kimicode.ts new file mode 100644 index 00000000..e843ae14 --- /dev/null +++ b/src/providers/kimicode.ts @@ -0,0 +1,360 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' + +import { extractBashCommands } from '../bash-utils.js' +import { calculateCost } from '../models.js' +import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js' + +type JsonObject = Record + +type SessionState = { + createdAt?: string + updatedAt?: string + workDir?: string +} + +type RequestContext = { + model: string + modelAlias: string + turnId: string + timestamp: string +} + +const toolNameMap: Record = { + Bash: 'Bash', + Shell: 'Bash', + bash: 'Bash', + shell: 'Bash', + Read: 'Read', + ReadFile: 'Read', + read_file: 'Read', + Write: 'Write', + WriteFile: 'Write', + write_file: 'Write', + Edit: 'Edit', + EditFile: 'Edit', + edit_file: 'Edit', + Grep: 'Grep', + grep: 'Grep', + Glob: 'Glob', + glob: 'Glob', + Agent: 'Agent', + Task: 'Agent', +} + +function asObject(value: unknown): JsonObject | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as JsonObject + : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function nonNegativeNumber(value: unknown): number { + const number = typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() ? Number(value) : NaN + return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : 0 +} + +function timestampIso(value: unknown): string { + if (typeof value === 'string') { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() + } + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + const milliseconds = value > 1_000_000_000_000 ? value : value * 1000 + const date = new Date(milliseconds) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() +} + +function kimicodeHome(override?: string): string { + return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code')) +} + +async function directoryEntries(path: string) { + try { + return await readdir(path, { withFileTypes: true }) + } catch { + return [] + } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + return false + } +} + +async function readState(sessionDir: string): Promise { + try { + const state = asObject(JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8'))) + if (!state) return {} + return { + createdAt: stringValue(state['createdAt']) || undefined, + updatedAt: stringValue(state['updatedAt']) || undefined, + workDir: stringValue(state['workDir']) || undefined, + } + } catch { + return {} + } +} + +function projectFromWorkDir(workDir: string, workDirKey: string): string { + if (workDir) return basename(workDir.replace(/[\\/]+$/, '')) || workDir + const match = /^wd_(.+)_[a-f0-9]{12}$/i.exec(workDirKey) + return match?.[1] || workDirKey.replace(/^wd_/, '') || 'kimicode' +} + +async function discoverSources(root: string): Promise { + const sources: SessionSource[] = [] + const sessionsDir = join(root, 'sessions') + + for (const workDirEntry of await directoryEntries(sessionsDir)) { + if (!workDirEntry.isDirectory() || !workDirEntry.name.startsWith('wd_')) continue + const workDirPath = join(sessionsDir, workDirEntry.name) + + for (const sessionEntry of await directoryEntries(workDirPath)) { + if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue + const sessionDir = join(workDirPath, sessionEntry.name) + const state = await readState(sessionDir) + const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name) + + for (const agentEntry of await directoryEntries(join(sessionDir, 'agents'))) { + if (!agentEntry.isDirectory()) continue + const wirePath = join(sessionDir, 'agents', agentEntry.name, 'wire.jsonl') + if (!await isFile(wirePath)) continue + sources.push({ + path: wirePath, + project, + provider: 'kimicode', + sourceId: agentEntry.name, + sourceLabel: agentEntry.name, + sourcePath: state.workDir, + }) + } + } + } + + return sources.sort((a, b) => a.path.localeCompare(b.path)) +} + +function sessionDirForWire(path: string): string { + return dirname(dirname(dirname(path))) +} + +function sessionIdForWire(path: string): string { + return basename(sessionDirForWire(path)).replace(/^session_/, '') +} + +function agentIdForWire(path: string): string { + return basename(dirname(path)) +} + +function turnIdFromStep(value: unknown): string { + const turnStep = stringValue(value) + if (!turnStep) return '' + return turnStep.split('.', 1)[0] ?? '' +} + +function inputText(value: unknown): string { + if (typeof value === 'string') return value + if (!Array.isArray(value)) return '' + return value + .map(part => { + const record = asObject(part) + return record?.['type'] === 'text' ? stringValue(record['text']) : '' + }) + .filter(Boolean) + .join('\n') +} + +function toolDetails(value: unknown): { name: string; bashCommands: string[] } | null { + const event = asObject(value) + if (!event || stringValue(event['type']) !== 'tool.call') return null + const rawName = stringValue(event['name']) + if (!rawName) return null + const name = toolNameMap[rawName] ?? rawName + + let args = asObject(event['args']) + if (!args && typeof event['args'] === 'string') { + try { + args = asObject(JSON.parse(event['args'])) + } catch { + args = null + } + } + const command = stringValue(args?.['command']) + return { + name, + bashCommands: name === 'Bash' && command ? extractBashCommands(command) : [], + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + let contents: string + try { + contents = await readFile(source.path, 'utf8') + } catch { + return + } + + const sessionDir = sessionDirForWire(source.path) + const sessionId = sessionIdForWire(source.path) + const agentId = source.sourceId || agentIdForWire(source.path) + const state = await readState(sessionDir) + const fallbackTimestamp = timestampIso(state.updatedAt) || timestampIso(state.createdAt) + const projectPath = state.workDir || source.sourcePath + const aliasModels = new Map() + const prompts = new Map() + let currentPrompt = '' + let currentRequest: RequestContext | null = null + let pendingTools: string[] = [] + let pendingBashCommands: string[] = [] + let usageOrdinal = 0 + + const lines = contents.split(/\r?\n/) + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]!.trim() + if (!line) continue + + let record: JsonObject | null + try { + record = asObject(JSON.parse(line)) + } catch { + continue + } + if (!record) continue + + const type = stringValue(record['type']) + if (type === 'turn.prompt') { + pendingTools = [] + pendingBashCommands = [] + currentPrompt = inputText(record['input']) + continue + } + + if (type === 'llm.request') { + const model = stringValue(record['model']) + const modelAlias = stringValue(record['modelAlias']) + const turnId = turnIdFromStep(record['turnStep']) + if (model && modelAlias) aliasModels.set(modelAlias, model) + if (turnId && currentPrompt) prompts.set(turnId, currentPrompt) + currentRequest = { + model, + modelAlias, + turnId, + timestamp: timestampIso(record['time']), + } + continue + } + + if (type === 'context.append_loop_event') { + const tool = toolDetails(record['event']) + if (tool) { + pendingTools.push(tool.name) + pendingBashCommands.push(...tool.bashCommands) + } + continue + } + + if (type !== 'usage.record') continue + const usage = asObject(record['usage']) + if (!usage) continue + + const usageAlias = stringValue(record['model']) + const realModel = aliasModels.get(usageAlias) ?? (currentRequest?.model || 'kimicode-unknown') + const turnId = currentRequest?.turnId || '' + const inputTokens = nonNegativeNumber(usage['inputOther']) + const outputTokens = nonNegativeNumber(usage['output']) + const cacheReadInputTokens = nonNegativeNumber(usage['inputCacheRead']) + const cacheCreationInputTokens = nonNegativeNumber(usage['inputCacheCreation']) + const timestamp = timestampIso(record['time']) || currentRequest?.timestamp || fallbackTimestamp + if (!timestamp) { + pendingTools = [] + pendingBashCommands = [] + continue + } + + const deduplicationKey = `kimicode:${sessionId}:${agentId}:${lineIndex + 1}:${usageOrdinal}` + usageOrdinal++ + if (seenKeys.has(deduplicationKey)) { + pendingTools = [] + pendingBashCommands = [] + continue + } + seenKeys.add(deduplicationKey) + + yield { + provider: 'kimicode', + model: realModel, + inputTokens, + outputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + cachedInputTokens: cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost( + realModel, + inputTokens, + outputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + 0, + ), + costIsEstimated: true, + tools: pendingTools, + bashCommands: pendingBashCommands, + timestamp, + speed: 'standard', + deduplicationKey, + turnId: turnId || undefined, + userMessage: prompts.get(turnId) ?? currentPrompt, + sessionId, + project: source.project, + projectPath, + } + + pendingTools = [] + pendingBashCommands = [] + } + }, + } +} + +export function createKimicodeProvider(homeOverride?: string): Provider { + return { + name: 'kimicode', + displayName: 'Kimi Code', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async probeRoots(): Promise { + return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }] + }, + + async discoverSessions(): Promise { + return discoverSources(kimicodeHome(homeOverride)) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const kimicode = createKimicodeProvider() diff --git a/src/session-cache.ts b/src/session-cache.ts index 9f8dc3f3..2ec6ab08 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -129,6 +129,7 @@ export const PROVIDER_ENV_VARS: Record = { qwen: ['QWEN_DATA_DIR'], 'ibm-bob': ['XDG_CONFIG_HOME'], quickdesk: ['QUICKWORK_HOME'], + kimicode: ['KIMI_CODE_HOME'], } // Names of providers whose cache entries are never evicted when source files @@ -159,6 +160,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { 'ibm-bob': 'worktree-project-grouping-v1', kiro: 'ide-parsing-v1-est-cost', quickdesk: 'emf-sqlite-v2-est-cost', + kimicode: 'wire-usage-v1-est-cost', 'kilo-code': 'worktree-project-grouping-v1', 'roo-code': 'worktree-project-grouping-v1', warp: 'worktree-project-grouping-v1-est-cost', diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index e84d0cc3..5f5f2012 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/kimicode.test.ts b/tests/providers/kimicode.test.ts new file mode 100644 index 00000000..6bcc089f --- /dev/null +++ b/tests/providers/kimicode.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { calculateCost } from '../../src/models.js' +import { createKimicodeProvider, kimicode } from '../../src/providers/kimicode.js' +import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js' + +type FixtureAgent = { + id: string + type?: string + lines: Array> +} + +let fixtureHome: string + +beforeEach(async () => { + fixtureHome = await mkdtemp(join(tmpdir(), 'kimicode-test-')) +}) + +afterEach(async () => { + delete process.env.KIMI_CODE_HOME + await rm(fixtureHome, { recursive: true, force: true }) +}) + +function prompt(text: string, time: number): Record { + return { + type: 'turn.prompt', + input: [{ type: 'text', text }], + origin: { kind: 'user' }, + time, + } +} + +function request( + turnStep: string, + modelAlias: string, + model: string, + time: number, + attempt?: string, +): Record { + return { + type: 'llm.request', + kind: 'loop', + provider: 'fixture-provider', + model, + modelAlias, + maxTokens: 4096, + messageCount: 2, + turnStep, + ...(attempt ? { attempt } : {}), + time, + } +} + +function usage( + modelAlias: string, + time: number, + tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number }, +): Record { + return { + type: 'usage.record', + model: modelAlias, + usage: { + inputOther: tokens.input, + output: tokens.output, + inputCacheRead: tokens.cacheRead ?? 0, + inputCacheCreation: tokens.cacheWrite ?? 0, + }, + usageScope: 'turn', + time, + } +} + +function tool( + name: string, + turnId: number, + args: Record = {}, +): Record { + return { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + name, + args, + turnId, + step: 1, + toolCallId: `tool-${turnId}-${name}`, + }, + } +} + +async function writeSession( + sessionId: string, + agents: FixtureAgent[], + workDir = '/workspace/neutral-project', +): Promise { + const sessionDir = join( + fixtureHome, + 'sessions', + 'wd_neutral-project_0123456789ab', + `session_${sessionId}`, + ) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'state.json'), JSON.stringify({ + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-01T10:05:00.000Z', + title: 'Sanitized fixture', + isCustomTitle: false, + workDir, + lastPrompt: 'sanitized fixture prompt', + agents: Object.fromEntries(agents.map(agent => [agent.id, { + homedir: '/workspace', + type: agent.type ?? (agent.id === 'main' ? 'main' : 'worker'), + parentAgentId: agent.id === 'main' ? null : 'main', + }])), + })) + + const paths: string[] = [] + for (const agent of agents) { + const agentDir = join(sessionDir, 'agents', agent.id) + await mkdir(agentDir, { recursive: true }) + const wirePath = join(agentDir, 'wire.jsonl') + await writeFile(wirePath, [ + JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }), + ...agent.lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)), + '', + ].join('\n')) + paths.push(wirePath) + } + return paths +} + +async function collect( + provider: Provider, + source: SessionSource, + seenKeys = new Set(), +): Promise { + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + return calls +} + +describe('Kimi Code provider', () => { + it('honors KIMI_CODE_HOME and reports the resolved doctor probe root', async () => { + process.env.KIMI_CODE_HOME = fixtureHome + + await expect(kimicode.probeRoots!()).resolves.toEqual([ + { path: fixtureHome, label: 'Kimi Code home' }, + ]) + }) + + it('discovers agent wires and uses state.json workDir metadata', async () => { + const paths = await writeSession('discovery', [ + { id: 'main', lines: [] }, + { id: 'agent-neutral', lines: [] }, + ]) + + const provider = createKimicodeProvider(fixtureHome) + const sources = await provider.discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.map(source => source.path)).toEqual([...paths].sort()) + expect(sources.map(source => source.sourceId)).toEqual(['agent-neutral', 'main']) + expect(sources.every(source => source.project === 'neutral-project')).toBe(true) + expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).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), + request('0.1', 'friendly-alias', 'kimi-k3', 1782900001000), + usage('friendly-alias', 1782900002000, { + input: 100, + output: 40, + cacheRead: 25, + cacheWrite: 10, + }), + ] }]) + const provider = createKimicodeProvider(fixtureHome) + const source = { path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main' } + const seen = new Set() + + const calls = await collect(provider, source, seen) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'kimicode', + model: 'kimi-k3', + inputTokens: 100, + outputTokens: 40, + cacheReadInputTokens: 25, + cacheCreationInputTokens: 10, + cachedInputTokens: 25, + costIsEstimated: true, + timestamp: '2026-07-01T10:00:02.000Z', + turnId: '0', + userMessage: 'Summarize the neutral module.', + sessionId: 'single-turn', + projectPath: '/workspace/neutral-project', + }) + expect(calls[0]!.model).not.toBe('friendly-alias') + expect(calls[0]!.costUSD).toBe(calculateCost('kimi-k3', 100, 40, 10, 25, 0)) + await expect(collect(provider, source, seen)).resolves.toHaveLength(0) + }) + + it('uses the usage alias to resolve the exact request model', async () => { + const [wirePath] = await writeSession('alias-model-resolution', [{ id: 'main', lines: [ + prompt('Compare two neutral model requests.', 1782900050000), + request('0.1', 'first-alias', 'kimi-k3', 1782900051000), + request('0.2', 'second-alias', 'glm-5.2', 1782900052000), + usage('first-alias', 1782900053000, { input: 24, output: 6 }), + ] }]) + const provider = createKimicodeProvider(fixtureHome) + + const calls = await collect(provider, { + path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main', + }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('kimi-k3') + }) + + it('attaches multiple tool calls and bash command names to the billed step', async () => { + const [wirePath] = await writeSession('multi-tool', [{ id: 'main', lines: [ + prompt('Inspect and verify the neutral fixture.', 1782900100000), + request('0.1', 'glm-alias', 'glm-5.2', 1782900101000), + tool('Write', 0, { path: 'notes.txt', content: 'neutral' }), + tool('Read', 0, { path: 'notes.txt' }), + tool('Bash', 0, { command: 'npm test && git status' }), + tool('Grep', 0, { pattern: 'neutral' }), + usage('glm-alias', 1782900102000, { input: 80, output: 20 }), + ] }]) + const provider = createKimicodeProvider(fixtureHome) + + const calls = await collect(provider, { + path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main', + }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Write', 'Read', 'Bash', 'Grep']) + expect(calls[0]!.bashCommands).toEqual(['npm', 'git']) + expect(calls[0]!.model).toBe('glm-5.2') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('does not carry failed-turn tools into the next turn usage', async () => { + const [wirePath] = await writeSession('failed-turn-tools', [{ id: 'main', lines: [ + prompt('Run a neutral command in the first turn.', 1782900150000), + request('0.1', 'first-alias', 'kimi-k3', 1782900151000), + tool('Bash', 0, { command: 'npm test' }), + prompt('Continue with a clean second turn.', 1782900160000), + usage('first-alias', 1782900161000, { input: 18, output: 4 }), + ] }]) + const provider = createKimicodeProvider(fixtureHome) + + const calls = await collect(provider, { + path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main', + }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([]) + expect(calls[0]!.bashCommands).toEqual([]) + }) + + it('keeps multiple turns in one session and attributes each prompt and model', async () => { + const [wirePath] = await writeSession('multi-turn', [{ id: 'main', lines: [ + prompt('Review the first neutral file.', 1782900200000), + request('0.1', 'first-alias', 'kimi-k3', 1782900201000), + usage('first-alias', 1782900202000, { input: 20, output: 5 }), + prompt('Now review the second neutral file.', 1782900210000), + request('1.1', 'second-alias', 'glm-5.2', 1782900211000), + usage('second-alias', 1782900212000, { input: 30, output: 7 }), + ] }]) + const provider = createKimicodeProvider(fixtureHome) + + const calls = await collect(provider, { + path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main', + }) + + expect(calls).toHaveLength(2) + expect(calls.map(call => call.sessionId)).toEqual(['multi-turn', 'multi-turn']) + expect(calls.map(call => call.turnId)).toEqual(['0', '1']) + expect(calls.map(call => call.model)).toEqual(['kimi-k3', 'glm-5.2']) + expect(calls.map(call => call.userMessage)).toEqual([ + 'Review the first neutral file.', + 'Now review the second neutral file.', + ]) + }) + + it('returns zero usage without throwing for a failed retry-only session', async () => { + const [wirePath] = await writeSession('failed-retry', [{ id: 'main', lines: [ + prompt('Perform a neutral operation.', 1782900300000), + request('0.1', 'retry-alias', 'kimi-k3', 1782900301000), + request('0.1', 'retry-alias', 'kimi-k3', 1782900302000, '2/10'), + request('0.1', 'retry-alias', 'kimi-k3', 1782900303000, '3/10'), + ] }]) + const provider = createKimicodeProvider(fixtureHome) + + await expect(collect(provider, { + path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main', + })).resolves.toEqual([]) + }) + + it('counts main and subagent usage once each under the same session', async () => { + await writeSession('subagent', [ + { id: 'main', lines: [ + prompt('Coordinate a neutral task.', 1782900400000), + request('0.1', 'main-alias', 'kimi-k3', 1782900401000), + usage('main-alias', 1782900402000, { input: 50, output: 10 }), + ] }, + { id: 'agent-helper', lines: [ + request('0.1', 'helper-alias', 'glm-5.2', 1782900403000), + usage('helper-alias', 1782900404000, { input: 15, output: 4 }), + ] }, + ]) + const provider = createKimicodeProvider(fixtureHome) + const sources = await provider.discoverSessions() + 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.sessionId === 'subagent')).toBe(true) + expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(65) + expect(new Set(calls.map(call => call.deduplicationKey)).size).toBe(2) + }) + + it('skips malformed JSONL lines and continues with later valid events', async () => { + const [wirePath] = await writeSession('malformed-lines', [{ id: 'main', lines: [ + prompt('Continue after malformed fixture data.', 1782900500000), + '{not valid json', + request('0.1', 'safe-alias', 'kimi-k3', 1782900501000), + 'null', + usage('safe-alias', 1782900502000, { input: 12, output: 3 }), + '{"type":"context.append_loop_event","event":', + ] }]) + const provider = createKimicodeProvider(fixtureHome) + + const calls = await collect(provider, { + path: wirePath!, project: 'neutral-project', provider: 'kimicode', sourceId: 'main', + }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ model: 'kimi-k3', inputTokens: 12, outputTokens: 3 }) + }) +}) From 023259584731c6b8da787981ef9a83742d07501b Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:11:29 +0300 Subject: [PATCH 2/2] docs(kimicode): subagent accounting verified at kimi-code source, no double count --- docs/providers/kimicode.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md index 1e3202fb..80300883 100644 --- a/docs/providers/kimicode.md +++ b/docs/providers/kimicode.md @@ -62,3 +62,12 @@ This agent-and-position-scoped key relies on the store invariant that each agent 2. Test main and subagent wires together with a shared deduplication set. 3. Preserve malformed-line and retry-only coverage when adding event variants. 4. Keep fixtures sanitized and rooted in a temporary `KIMI_CODE_HOME`. + +## Subagent accounting (verified at source) + +Subagent token usage is recorded only in the subagent's own `wire.jsonl`. +When a subagent completes, the parent's wire receives a `subagent.completed` +summary event (informational, ignored by this parser), never a `usage.record` +mirroring the child's tokens (see MoonshotAI/kimi-code, +`packages/agent-core/src/session/subagent-host.ts`). Summing `usage.record` +events across all agent wires therefore does not double count.