Skip to content

Commit 14ffec8

Browse files
committed
feat(cli): add Kimi Code as a backfill source
Kimi writes one wire.jsonl per agent run and ships two schemas that are both still on disk: the newer top-level `usage.record` lines with camelCase counts and epoch-millisecond `time`, and the older StatusUpdate payload with snake_case counts and fractional epoch-second `timestamp`. Read both, keyed off the line's own `type`. Session-scoped usage records are cumulative totals of the turn records before them, so only `usageScope == "turn"` is counted. Model names carry a `kimi-code/` provider route that pricing catalogues do not, so it is stripped; the old format names no model per line and falls back to config.json. `inputOther` is cache-exclusive upstream, so both cache buckets fold back into codetime's cache-inclusive tokensInput. Discovery cannot use the generic "every .jsonl under the root" listing: runs keep other jsonl next to wire.jsonl, and the file sits at two different depths (sessions/<group>/<session>/ and sessions/<ws>/<session>/agents/<agent>/). KIMI_DATA_DIR replaces the default ~/.kimi and ~/.kimi-code roots rather than adding to them, matching ccusage. No backfill schema bump: this only adds a source that was never imported before, so existing rollups are unaffected. Claude-Session: https://claude.ai/code/session_019u3RKiNJY1sRknveHJy9iJ
1 parent ff37d6d commit 14ffec8

4 files changed

Lines changed: 581 additions & 1 deletion

File tree

packages/cli/src/adapters/kimi.ts

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import type { CanonicalEvent, MetricBag } from '@codetime/shared'
2+
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
3+
import { readFile, stat } from 'node:fs/promises'
4+
import path from 'node:path'
5+
import {
6+
AGENT_TIME_SCHEMA_VERSION,
7+
createStableHash,
8+
createWorkspaceId,
9+
} from '@codetime/shared'
10+
import { withBackfillRefs } from '../lib/backfill.js'
11+
import {
12+
numberField,
13+
objectField,
14+
stringField,
15+
} from '../lib/fields.js'
16+
import { listFilesByExtensions, pathExists } from '../lib/fs.js'
17+
import { parseJsonLine } from '../lib/jsonl.js'
18+
19+
interface BackfillSourceFile {
20+
path: string
21+
modifiedAt: string
22+
}
23+
24+
// Kimi writes one `wire.jsonl` per agent run under a sessions tree. Two layouts
25+
// exist and both are still on disk in the wild:
26+
// old: <root>/sessions/<group>/<session>/wire.jsonl (3 segments)
27+
// new: <root>/sessions/<workspace>/<session>/agents/<agent>/wire.jsonl (5 segments)
28+
// Mirrors ccusage is_kimi_wire_file (adapter/kimi/paths.rs).
29+
const KIMI_SESSIONS_DIR = 'sessions'
30+
const KIMI_WIRE_FILE = 'wire.jsonl'
31+
const KIMI_WIRE_DEPTHS = new Set([3, 5])
32+
33+
// The subscription tier name Kimi reports when config.json names no model.
34+
// ccusage maps this to a concrete moonshot/kimi-k2.x id via a hardcoded release
35+
// cutoff so its offline pricing table can resolve a rate. codetime prices on the
36+
// server from OpenRouter instead, so the raw name is stored as-is rather than
37+
// baked against a timestamp that goes stale.
38+
const KIMI_DEFAULT_MODEL = 'kimi-for-coding'
39+
40+
// ── Paths ──
41+
42+
function kimiDataDirs(home: string, env?: AdapterEnv): string[] {
43+
const configured = env?.KIMI_DATA_DIR
44+
if (configured) {
45+
// Explicit override wins outright, matching ccusage: when KIMI_DATA_DIR is
46+
// set the default roots are not searched at all.
47+
return configured
48+
.split(',')
49+
.map(entry => entry.trim())
50+
.filter(Boolean)
51+
.map(entry => path.resolve(entry))
52+
}
53+
return [path.join(home, '.kimi'), path.join(home, '.kimi-code')]
54+
}
55+
56+
function isKimiWireFile(sessionsDir: string, filePath: string): boolean {
57+
if (path.basename(filePath) !== KIMI_WIRE_FILE) {
58+
return false
59+
}
60+
const relative = path.relative(sessionsDir, filePath)
61+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
62+
return false
63+
}
64+
return KIMI_WIRE_DEPTHS.has(relative.split(path.sep).filter(Boolean).length)
65+
}
66+
67+
export async function kimiBackfillFiles(
68+
sourceRoot: string | undefined,
69+
home: string,
70+
env: AdapterEnv | undefined,
71+
): Promise<BackfillSourceFile[]> {
72+
const roots = sourceRoot ? [path.resolve(sourceRoot)] : kimiDataDirs(home, env)
73+
const files: string[] = []
74+
for (const root of roots) {
75+
const sessionsDir = path.join(root, KIMI_SESSIONS_DIR)
76+
const candidates = await listFilesByExtensions(sessionsDir, ['.jsonl'])
77+
files.push(...candidates.filter(file => isKimiWireFile(sessionsDir, file)))
78+
}
79+
const unique = [...new Set(files)].sort()
80+
return Promise.all(unique.map(async (filePath) => {
81+
const info = await stat(filePath)
82+
return { path: filePath, modifiedAt: info.mtime.toISOString() }
83+
}))
84+
}
85+
86+
// `<root>/sessions/<ws>/<session>/agents/<agent>/wire.jsonl` and the older
87+
// `<root>/sessions/<group>/<session>/wire.jsonl` both put the session two or
88+
// four levels above the file. Mirrors ccusage extract_session_id.
89+
function kimiSessionIdFromPath(filePath: string): string {
90+
const agentDir = path.dirname(filePath)
91+
const sessionDir = path.basename(path.dirname(agentDir)) === 'agents'
92+
? path.dirname(path.dirname(agentDir))
93+
: agentDir
94+
return path.basename(sessionDir)
95+
}
96+
97+
// The kimi root holding config.json, walked back up from the wire file.
98+
function kimiRootFromWirePath(filePath: string): string | undefined {
99+
const agentDir = path.dirname(filePath)
100+
const isNewLayout = path.basename(path.dirname(agentDir)) === 'agents'
101+
// new: root/sessions/<ws>/<session>/agents/<agent>/wire.jsonl
102+
// old: root/sessions/<group>/<session>/wire.jsonl
103+
const root = isNewLayout
104+
? path.resolve(agentDir, '../../../../..')
105+
: path.resolve(agentDir, '../../..')
106+
return root || undefined
107+
}
108+
109+
async function kimiConfiguredModel(filePath: string): Promise<string> {
110+
const root = kimiRootFromWirePath(filePath)
111+
if (!root) {
112+
return KIMI_DEFAULT_MODEL
113+
}
114+
try {
115+
const raw = JSON.parse(await readFile(path.join(root, 'config.json'), 'utf8')) as unknown
116+
const model = stringField(raw as Record<string, unknown>, 'model')
117+
return model || KIMI_DEFAULT_MODEL
118+
}
119+
catch {
120+
return KIMI_DEFAULT_MODEL
121+
}
122+
}
123+
124+
// ── Parser ──
125+
126+
/**
127+
* Token counts from either wire schema.
128+
*
129+
* The two schemas name the same four buckets differently — the new Kimi Code
130+
* format uses camelCase (`inputOther`, `inputCacheRead`, `inputCacheCreation`)
131+
* while the old StatusUpdate payload uses snake_case — but the meaning is
132+
* identical, so both funnel through here. `inputOther` is cache-EXCLUSIVE, as in
133+
* ccusage; codetime's tokensInput is cache-inclusive, so the cache buckets are
134+
* folded back in.
135+
*/
136+
function kimiUsageMetrics(
137+
usage: Record<string, unknown>,
138+
fields: { input: string, output: string, cacheCreation: string, cacheRead: string },
139+
explicitTotal = 0,
140+
): Partial<MetricBag> | undefined {
141+
const input = numberField(usage, fields.input) || 0
142+
const output = numberField(usage, fields.output) || 0
143+
const cacheCreation = numberField(usage, fields.cacheCreation) || 0
144+
const cacheRead = numberField(usage, fields.cacheRead) || 0
145+
// ccusage apply_total_token_fallback: an explicit total can only ADD tokens the
146+
// parts do not account for (folded into billable output), never shrink the
147+
// parts sum.
148+
const partsSum = input + output + cacheCreation + cacheRead
149+
const missing = Math.max(0, explicitTotal - partsSum)
150+
const billableOutput = output + missing
151+
const totalTokens = partsSum + missing
152+
if (totalTokens <= 0) {
153+
return undefined
154+
}
155+
const cachedInput = cacheCreation + cacheRead
156+
return {
157+
tokensInput: (input + cachedInput) || undefined,
158+
tokensCachedInput: cachedInput || undefined,
159+
tokensCacheCreationInput: cacheCreation || undefined,
160+
tokensCacheReadInput: cacheRead || undefined,
161+
tokensOutput: billableOutput || undefined,
162+
tokensTotal: totalTokens,
163+
modelCalls: 1,
164+
}
165+
}
166+
167+
interface KimiUsageLine {
168+
metrics: Partial<MetricBag>
169+
model: string
170+
ts: string | undefined
171+
}
172+
173+
// New Kimi Code format: a top-level `usage.record` line.
174+
function kimiUsageFromRecord(raw: Record<string, unknown>): KimiUsageLine | undefined {
175+
// Session-scoped records are cumulative totals of the turn records that
176+
// precede them — counting both would double the session.
177+
if (stringField(raw, 'usageScope') !== 'turn') {
178+
return undefined
179+
}
180+
const metrics = kimiUsageMetrics(objectField(raw, 'usage'), {
181+
input: 'inputOther',
182+
output: 'output',
183+
cacheCreation: 'inputCacheCreation',
184+
cacheRead: 'inputCacheRead',
185+
})
186+
if (!metrics) {
187+
return undefined
188+
}
189+
const model = stringField(raw, 'model')
190+
const time = numberField(raw, 'time')
191+
return {
192+
metrics,
193+
// Kimi Code prefixes the model with its provider route; the bare id is what
194+
// pricing catalogues carry.
195+
model: (model || KIMI_DEFAULT_MODEL).replace(/^kimi-code\//, ''),
196+
// `time` is epoch milliseconds here, unlike the old format's seconds.
197+
ts: time !== undefined && Number.isFinite(time) ? new Date(time).toISOString() : undefined,
198+
}
199+
}
200+
201+
// Old format: `message.type === "StatusUpdate"` carrying `payload.token_usage`.
202+
function kimiUsageFromStatusUpdate(
203+
raw: Record<string, unknown>,
204+
configuredModel: string,
205+
): KimiUsageLine | undefined {
206+
const message = objectField(raw, 'message')
207+
if (stringField(message, 'type') !== 'StatusUpdate') {
208+
return undefined
209+
}
210+
const payload = objectField(message, 'payload')
211+
const tokenUsage = objectField(payload, 'token_usage')
212+
if (Object.keys(tokenUsage).length === 0) {
213+
return undefined
214+
}
215+
const metrics = kimiUsageMetrics(
216+
tokenUsage,
217+
{ input: 'input_other', output: 'output', cacheCreation: 'input_cache_creation', cacheRead: 'input_cache_read' },
218+
numberField(tokenUsage, 'total') || 0,
219+
)
220+
if (!metrics) {
221+
return undefined
222+
}
223+
const seconds = numberField(raw, 'timestamp')
224+
return {
225+
metrics,
226+
// The old format names no model per line; config.json is the only source.
227+
model: configuredModel,
228+
ts: seconds !== undefined && Number.isFinite(seconds)
229+
? new Date(Math.trunc(seconds * 1000)).toISOString()
230+
: undefined,
231+
}
232+
}
233+
234+
async function parseKimiSessionFile(
235+
filePath: string,
236+
options: Record<string, unknown> & { _: string[] },
237+
): Promise<CanonicalEvent[]> {
238+
const text = await readFile(filePath, 'utf8')
239+
const lines = text.split('\n').filter(Boolean)
240+
if (lines.length === 0) {
241+
return []
242+
}
243+
244+
const sourcePathHash = `sha256:${createStableHash(filePath)}`
245+
const sessionId = `kimi_${kimiSessionIdFromPath(filePath)}`
246+
const configuredModel = await kimiConfiguredModel(filePath)
247+
// A malformed or absent per-line timestamp degrades to the file's mtime rather
248+
// than dropping the line, mirroring ccusage's file_modified_timestamp.
249+
let fallbackTs: string
250+
try {
251+
const info = await stat(filePath)
252+
fallbackTs = info.mtime.toISOString()
253+
}
254+
catch {
255+
fallbackTs = new Date().toISOString()
256+
}
257+
258+
const events: CanonicalEvent[] = []
259+
let sessionStarted = false
260+
261+
for (const [index, line] of lines.entries()) {
262+
const lineNumber = index + 1
263+
const raw = parseJsonLine(line)
264+
if (!raw) {
265+
continue
266+
}
267+
const topType = stringField(raw, 'type')
268+
if (topType === 'metadata') {
269+
continue
270+
}
271+
const usage = topType === 'usage.record'
272+
? kimiUsageFromRecord(raw)
273+
: kimiUsageFromStatusUpdate(raw, configuredModel)
274+
if (!usage) {
275+
continue
276+
}
277+
const ts = usage.ts || fallbackTs
278+
279+
if (!sessionStarted) {
280+
sessionStarted = true
281+
events.push(withBackfillRefs(baseKimiEvent({
282+
ts,
283+
type: 'session.started',
284+
sessionId,
285+
model: usage.model,
286+
confidence: 'derived',
287+
}), { filePath, sourcePathHash, lineNumber, topType, payloadType: 'session', options }))
288+
}
289+
290+
events.push(withBackfillRefs(baseKimiEvent({
291+
ts,
292+
type: 'model.usage',
293+
sessionId,
294+
model: usage.model,
295+
confidence: 'partial',
296+
metrics: usage.metrics,
297+
}), { filePath, sourcePathHash, lineNumber, topType, payloadType: 'usage', options }))
298+
}
299+
300+
return events
301+
}
302+
303+
function baseKimiEvent(
304+
event: Omit<CanonicalEvent, 'schemaVersion' | 'source' | 'agent' | 'workspaceId'>,
305+
): CanonicalEvent {
306+
return {
307+
schemaVersion: AGENT_TIME_SCHEMA_VERSION,
308+
source: 'kimi',
309+
agent: 'kimi',
310+
workspaceId: createWorkspaceId({ projectName: event.project, repoRoot: event.cwd }),
311+
...event,
312+
}
313+
}
314+
315+
// ── Adapter ──
316+
317+
export function createKimiAdapter(): AgentAdapter {
318+
return {
319+
id: 'kimi',
320+
label: 'Kimi Code',
321+
agentName: 'kimi',
322+
kind: 'agent',
323+
324+
detectPath(home, env) {
325+
return kimiDataDirs(home, env)[0]
326+
},
327+
// Kimi exposes no plugin/hook surface, so there is nothing for codetime to
328+
// "install". Report installed = true whenever the sessions tree is on disk
329+
// so `detect` shows it as already covered via backfill (same as Amp).
330+
installedPath(home, env) {
331+
return path.join(kimiDataDirs(home, env)[0], KIMI_SESSIONS_DIR)
332+
},
333+
async isInstalled(home, env) {
334+
for (const root of kimiDataDirs(home, env)) {
335+
try {
336+
if (await pathExists(path.join(root, KIMI_SESSIONS_DIR))) {
337+
return true
338+
}
339+
}
340+
catch {
341+
continue
342+
}
343+
}
344+
return false
345+
},
346+
installEntries(): InstallEntry[] {
347+
return []
348+
},
349+
350+
sourcePaths(home, env) {
351+
return kimiDataDirs(home, env).map(root => path.join(root, KIMI_SESSIONS_DIR))
352+
},
353+
354+
parseSessionFile: parseKimiSessionFile,
355+
}
356+
}

packages/cli/src/cli.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ampBackfillFiles, createAmpAdapter } from './adapters/amp.js'
2424
import { createClaudeCodeAdapter } from './adapters/claude-code.js'
2525
import { codexBackfillFiles, createCodexAdapter } from './adapters/codex.js'
2626
import { createGeminiAdapter, geminiBackfillFiles } from './adapters/gemini.js'
27+
import { createKimiAdapter, kimiBackfillFiles } from './adapters/kimi.js'
2728
import { createOpenCodeAdapter, opencodeBackfillFiles } from './adapters/opencode.js'
2829
import { createPiAdapter } from './adapters/pi.js'
2930
import { AdapterRegistry } from './adapters/registry.js'
@@ -59,6 +60,7 @@ function createRegistry(): AdapterRegistry {
5960
registry.register(createOpenCodeAdapter())
6061
registry.register(createAmpAdapter())
6162
registry.register(createGeminiAdapter())
63+
registry.register(createKimiAdapter())
6264
return registry
6365
}
6466

@@ -642,6 +644,12 @@ async function listBackfillSourceFiles(
642644
if (source.id === 'gemini') {
643645
return canonicalizeBackfillFiles(await geminiBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env))
644646
}
647+
// Kimi keeps non-usage jsonl next to each run's wire.jsonl and nests it at two
648+
// different depths, so the generic "every .jsonl under the root" listing would
649+
// both miss and over-collect.
650+
if (source.id === 'kimi') {
651+
return canonicalizeBackfillFiles(await kimiBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env))
652+
}
645653
if (source.id === 'codex') {
646654
const files = await codexBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env)
647655
return canonicalizeBackfillFiles(files

0 commit comments

Comments
 (0)