From 243cd2c6c2c7c12370730057476ecc4e259c93f4 Mon Sep 17 00:00:00 2001 From: metapeka Date: Sun, 3 May 2026 09:08:51 +0300 Subject: [PATCH] feat: add Kilo CLI provider Adds provider for Kilo CLI (kilo serve / kilo run) that reads sessions from ~/.local/share/kilo/kilo*.db using the same SQLite schema as OpenCode. - New provider: src/providers/kilo-cli.ts - Registered in src/providers/index.ts with lazy loading - Supports KILO_DATA_HOME env var for custom data directory - Deduplication key: kilo-cli:{sessionId}:{messageId} --- src/providers/index.ts | 22 ++- src/providers/kilo-cli.ts | 317 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 src/providers/kilo-cli.ts diff --git a/src/providers/index.ts b/src/providers/index.ts index 5cf8092..b94a870 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -11,6 +11,21 @@ import { qwen } from './qwen.js' import { rooCode } from './roo-code.js' import type { Provider, SessionSource } from './types.js' +let kiloCliProvider: Provider | null = null +let kiloCliLoadAttempted = false + +async function loadKiloCli(): Promise { + if (kiloCliLoadAttempted) return kiloCliProvider + kiloCliLoadAttempted = true + try { + const { kilo_cli } = await import('./kilo-cli.js') + kiloCliProvider = kilo_cli + return kilo_cli + } catch { + return null + } +} + let antigravityProvider: Provider | null = null let antigravityLoadAttempted = false @@ -89,13 +104,14 @@ async function loadCursorAgent(): Promise { const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { - const [ag, gs, cursor, opencode, cursorAgent] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent()]) + const [ag, gs, cursor, opencode, cursorAgent, kilo] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadKiloCli()]) const all = [...coreProviders] if (ag) all.push(ag) if (gs) all.push(gs) if (cursor) all.push(cursor) if (opencode) all.push(opencode) if (cursorAgent) all.push(cursorAgent) + if (kilo) all.push(kilo) return all } @@ -135,5 +151,9 @@ export async function getProvider(name: string): Promise { const ca = await loadCursorAgent() return ca ?? undefined } + if (name === 'kilo-cli') { + const kc = await loadKiloCli() + return kc ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/src/providers/kilo-cli.ts b/src/providers/kilo-cli.ts new file mode 100644 index 0000000..f5ee047 --- /dev/null +++ b/src/providers/kilo-cli.ts @@ -0,0 +1,317 @@ +import { readdir } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { + Provider, + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +type MessageRow = { + id: string + time_created: number + data: string +} + +type PartRow = { + message_id: string + data: string +} + +type SessionRow = { + id: string + directory: string + title: string + time_created: number +} + +type MessageData = { + role: string + modelID?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cache?: { read?: number; write?: number } + } +} + +type PartData = { + type: string + text?: string + tool?: string + state?: { input?: { command?: string } } +} + +const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + skill: 'Skill', + patch: 'Patch', +} + +function sanitize(dir: string): string { + return dir.replace(/^\//, '').replace(/\//g, '-') +} + +function getDataDir(dataDir?: string): string { + const base = + dataDir ?? + process.env['KILO_DATA_HOME'] ?? + join(homedir(), '.local', 'share') + return join(base, 'kilo') +} + +async function findDbFiles(dir: string): Promise { + try { + const entries = await readdir(dir) + return entries + .filter((f) => f.startsWith('kilo') && f.endsWith('.db')) + .map((f) => join(dir, f)) + } catch { + return [] + } +} + +function parseTimestamp(raw: number): string { + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>( + "SELECT COUNT(*) as cnt FROM session LIMIT 1" + ) + db.query<{ cnt: number }>( + "SELECT COUNT(*) as cnt FROM message LIMIT 1" + ) + return true + } catch { + return false + } +} + +function createParser( + source: SessionSource, + seenKeys: Set, +): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const segments = source.path.split(':') + const sessionId = segments[segments.length - 1]! + const dbPath = segments.slice(0, -1).join(':') + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Kilo CLI database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + if (!validateSchema(db)) { + process.stderr.write('codeburn: Kilo CLI storage format not recognized. You may need to update CodeBurn.\n') + return + } + + const messages = db.query( + 'SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC', + [sessionId], + ) + + const parts = db.query( + 'SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id', + [sessionId], + ) + + const partsByMsg = new Map() + for (const part of parts) { + try { + const parsed = JSON.parse(part.data) as PartData + const list = partsByMsg.get(part.message_id) ?? [] + list.push(parsed) + partsByMsg.set(part.message_id, list) + } catch { + // skip corrupt part data + } + } + + let currentUserMessage = '' + + for (const msg of messages) { + let data: MessageData + try { + data = JSON.parse(msg.data) as MessageData + } catch { + continue + } + + if (data.role === 'user') { + const textParts = (partsByMsg.get(msg.id) ?? []) + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .filter(Boolean) + if (textParts.length > 0) { + currentUserMessage = textParts.join(' ') + } + continue + } + + if (data.role !== 'assistant') continue + + const tokens = { + input: data.tokens?.input ?? 0, + output: data.tokens?.output ?? 0, + reasoning: data.tokens?.reasoning ?? 0, + cacheRead: data.tokens?.cache?.read ?? 0, + cacheWrite: data.tokens?.cache?.write ?? 0, + } + + const allZero = + tokens.input === 0 && + tokens.output === 0 && + tokens.reasoning === 0 && + tokens.cacheRead === 0 && + tokens.cacheWrite === 0 + if (allZero && (data.cost ?? 0) === 0) continue + + const msgParts = partsByMsg.get(msg.id) ?? [] + const toolParts = msgParts.filter((p) => p.type === 'tool') + const tools = toolParts + .map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '') + .filter(Boolean) + + const bashCommands = toolParts + .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') + .flatMap((p) => extractBashCommands(p.state!.input!.command!)) + + const dedupKey = `kilo-cli:${sessionId}:${msg.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = data.modelID ?? 'unknown' + let costUSD = calculateCost( + model, + tokens.input, + tokens.output + tokens.reasoning, + tokens.cacheWrite, + tokens.cacheRead, + 0, + ) + + if (costUSD === 0 && typeof data.cost === 'number' && data.cost > 0) { + costUSD = data.cost + } + + yield { + provider: 'kilo-cli', + model, + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheCreationInputTokens: tokens.cacheWrite, + cacheReadInputTokens: tokens.cacheRead, + cachedInputTokens: tokens.cacheRead, + reasoningTokens: tokens.reasoning, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: parseTimestamp(msg.time_created), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: currentUserMessage, + sessionId, + } + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + const rows = db.query( + 'SELECT id, directory, title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', + ) + + return rows.map((row) => ({ + path: `${dbPath}:${row.id}`, + project: row.directory ? sanitize(row.directory) : sanitize(row.title), + provider: 'kilo-cli', + })) + } catch { + return [] + } finally { + db.close() + } +} + +export function createKiloCliProvider(dataDir?: string): Provider { + const dir = getDataDir(dataDir) + + return { + name: 'kilo-cli', + displayName: 'Kilo CLI', + + modelDisplayName(model: string): string { + const stripped = model.replace(/^[^/]+\//, '') + return getShortModelName(stripped) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + + const dbPaths = await findDbFiles(dir) + if (dbPaths.length === 0) return [] + + const sessions: SessionSource[] = [] + for (const dbPath of dbPaths) { + sessions.push(...await discoverFromDb(dbPath)) + } + return sessions + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + ): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const kilo_cli = createKiloCliProvider()