Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions packages/cli/src/providers/kilo-code.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { join } from 'path'
import { homedir } from 'os'

import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline'
import { decodeOpenCodeSession } from '@codeburn/core/providers/opencode-session'
import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline'
import type { OpenCodeSessionDecodedCall } from '@codeburn/core/providers/opencode-session'

import { discoverSqliteSessions, readSqliteSessionRecords, type SqliteProviderConfig } from './sqlite-session-parser.js'
import { discoverClineTasks, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js'
import { toOpenCodeProviderCall } from './opencode.js'
import { createBridgedProvider } from './bridge.js'
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'

const EXTENSION_ID = 'kilocode.kilo-code'
const PROVIDER_NAME = 'kilo-code'
Expand All @@ -18,10 +25,20 @@ function getSqliteConfig(): SqliteProviderConfig {
}
}

type KiloRich =
| { arm: 'cline'; call: VscodeClineDecodedCall }
| { arm: 'sqlite'; call: OpenCodeSessionDecodedCall }

export function createKiloCodeProvider(overrideDir?: string | string[]): Provider {
const sqliteConfig = getSqliteConfig()

return {
// Provider-instance-scoped handoff for the verbose-stderr counts and session
// id on the SQLite arm. readRecords and decode are called synchronously in
// sequence inside one parse() generator, so this is safe.
let lastSqliteCounts: { messageCount: number; partCount: number } | undefined
let lastSessionId: string | undefined

return createBridgedProvider<KiloRich>({
name: PROVIDER_NAME,
displayName: 'KiloCode',

Expand All @@ -41,13 +58,59 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide
return [...oldSessions, ...dbSessions]
},

createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
async readRecords(source) {
if (source.path.includes('.db:')) {
return createSqliteSessionParser(source, seenKeys, sqliteConfig)
const result = await readSqliteSessionRecords(source, sqliteConfig)
if (result === null) return null
const envelope = result.records[0] as { sessionId: string }
lastSessionId = envelope.sessionId
lastSqliteCounts = { messageCount: result.messageCount, partCount: result.partCount }
return result.records
}
return createClineParser(source, seenKeys, PROVIDER_NAME)
return readClineRecords(source)
},
}

decode(input) {
const envelope = input.records[0] as { kind: string; sessionId?: string } | undefined
if (!envelope) return { calls: [] }

if (envelope.kind === 'cline-task') {
const { calls } = decodeVscodeCline({
records: input.records,
context: input.context,
seenKeys: input.seenKeys,
})
return { calls: calls.map(call => ({ arm: 'cline' as const, call })) }
}

if (envelope.kind === 'sqlite') {
const { calls, diagnostics } = decodeOpenCodeSession({
records: input.records,
context: input.context,
seenKeys: input.seenKeys,
})
// Preserve the pre-migration CODEBURN_VERBOSE notice for the SQLite arm.
if (calls.length === 0 && lastSqliteCounts && lastSqliteCounts.messageCount > 0
&& process.env['CODEBURN_VERBOSE'] === '1' && lastSessionId) {
const parseFailCount = diagnostics.filter(d => d.code === 'malformed-json').length
const roleSkipCount = diagnostics.filter(d => d.code === 'unknown-shape').length
process.stderr.write(
`codeburn: KiloCode session ${lastSessionId} has ${lastSqliteCounts.messageCount} messages ` +
`(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` +
`but yielded 0 calls. Parts: ${lastSqliteCounts.partCount}.\n`
)
}
return { calls: calls.map(call => ({ arm: 'sqlite' as const, call })) }
}

return { calls: [] }
},

toProviderCall(rich) {
if (rich.arm === 'cline') return toClineProviderCall(rich.call)
return toOpenCodeProviderCall(rich.call)
},
})
}

export const kiloCode = createKiloCodeProvider()
153 changes: 71 additions & 82 deletions packages/cli/src/providers/opencode-file-parser.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { readdir, readFile } from 'fs/promises'
import { join } from 'path'

import { buildAssistantCall, sanitize, type MessageData, type PartData } from './session-message.js'
import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import { sanitize } from './session-message.js'
import type { SessionSource } from './types.js'

// OpenCode 1.1+ stores sessions as file-based JSON instead of a SQLite DB:
// storage/session/<projectID>/<sessionID>.json session metadata
// storage/message/<sessionID>/<messageID>.json one file per message
// storage/part/<messageID>/<partID>.json one file per part
// The message/part shape matches the SQLite layout, so the per-message build
// logic is shared via buildAssistantCall.
// logic is shared via @codeburn/core/providers/opencode-session.

type SessionMeta = {
id?: string
Expand All @@ -18,8 +18,24 @@ type SessionMeta = {
time?: { created?: number }
}

type FileMessageData = MessageData & {
type FileMessageData = {
id?: string
role: string
modelID?: string
model?: string
cost?: number
tokens?: {
input?: number
output?: number
reasoning?: number
cache?: { read?: number; write?: number }
}
usage?: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
time?: { created?: number }
}

Expand All @@ -31,23 +47,6 @@ async function readJson<T>(path: string): Promise<T | null> {
}
}

async function readParts(dataDir: string, messageId: string): Promise<PartData[]> {
const dir = join(dataDir, 'storage', 'part', messageId)
let files: string[]
try {
files = (await readdir(dir)).sort()
} catch {
return []
}
const parts: PartData[] = []
for (const f of files) {
if (!f.endsWith('.json')) continue
const part = await readJson<PartData>(join(dir, f))
if (part) parts.push(part)
}
return parts
}

export async function discoverOpenCodeFileSessions(
dataDir: string,
providerName: string,
Expand Down Expand Up @@ -83,72 +82,62 @@ export async function discoverOpenCodeFileSessions(
return sources
}

export function createOpenCodeFileSessionParser(
export async function readOpenCodeFileRecords(
source: SessionSource,
seenKeys: Set<string>,
dataDir: string,
providerName: string,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const meta = await readJson<SessionMeta>(source.path)
if (!meta?.id) return
const sessionId = meta.id

const messageDir = join(dataDir, 'storage', 'message', sessionId)
let messageFiles: string[]
try {
messageFiles = await readdir(messageDir)
} catch {
return
}

const messages: Array<{ id: string; data: FileMessageData }> = []
for (const f of messageFiles) {
if (!f.endsWith('.json')) continue
const data = await readJson<FileMessageData>(join(messageDir, f))
if (!data) continue
messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data })
}
messages.sort((a, b) => {
const byTime = (a.data.time?.created ?? 0) - (b.data.time?.created ?? 0)
if (byTime !== 0) return byTime
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})

let currentUserMessage = ''
for (const { id, data } of messages) {
if (data.role === 'user') {
const parts = await readParts(dataDir, id)
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.filter(Boolean)
.join(' ')
if (text) currentUserMessage = text
continue
}
): Promise<unknown[] | null> {
const meta = await readJson<SessionMeta>(source.path)
if (!meta?.id) return null
const sessionId = meta.id

if (data.role !== 'assistant' && data.role !== 'model') continue

const dedupKey = `${providerName}:${sessionId}:${id}`
if (seenKeys.has(dedupKey)) continue
const messageDir = join(dataDir, 'storage', 'message', sessionId)
let messageFiles: string[]
try {
messageFiles = await readdir(messageDir)
} catch {
return null
}

const parts = await readParts(dataDir, id)
const call = buildAssistantCall({
providerName,
dedupKey,
sessionId,
data,
parts,
timeCreatedMs: data.time?.created ?? meta.time?.created ?? 0,
userMessage: currentUserMessage,
})
if (!call) continue
// Message-file JSON.parse stays host-side here (unlike the SQLite arm, where
// core parses), because the message ids determine which part directories to
// read: the parse is a precondition of the I/O, not a step after it.
const messages: Array<{ id: string; data: FileMessageData }> = []
for (const f of messageFiles) {
if (!f.endsWith('.json')) continue
const data = await readJson<FileMessageData>(join(messageDir, f))
if (!data) continue
messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data })
}

seenKeys.add(dedupKey)
yield call
// Part files are read eagerly for every message. The original read them lazily
// only for messages that survived role and dedup checks; this is a strict
// superset with identical output, changing only I/O volume.
const partsRawByMessageId = new Map<string, string[]>()
for (const { id } of messages) {
const partDir = join(dataDir, 'storage', 'part', id)
let files: string[]
try {
files = (await readdir(partDir)).sort()
} catch {
continue
}
const rawParts: string[] = []
for (const f of files) {
if (!f.endsWith('.json')) continue
try {
rawParts.push(await readFile(join(partDir, f), 'utf8'))
} catch {
// skip unreadable part file
}
},
}
partsRawByMessageId.set(id, rawParts)
}

return [{
kind: 'file',
sessionId,
messages,
partsRawByMessageId,
metaTimeCreatedMs: meta.time?.created,
}]
}
Loading