From ec2de6a642b6f0353a080ee8a2e8da57cc82cca0 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:24:14 -0700 Subject: [PATCH] Add OpenClaw, Roo Code, and KiloCode providers (#175) - OpenClaw: JSONL parser with multi-path discovery, tool extraction (toolCall + tool_use block types), model tracking via model_change and custom model-snapshot events - Roo Code + KiloCode: shared Cline-family parser extracts model from tags in api_conversation_history.json, strips provider prefixes from model names - Add cline-auto and openclaw-auto aliases and display names - Add menubar provider filters and tab colors for all three - Show cached data instantly instead of blocking on CLI refresh --- mac/Sources/CodeBurnMenubar/AppStore.swift | 21 +- .../CodeBurnMenubar/Views/AgentTabStrip.swift | 3 + .../Views/MenuBarContent.swift | 2 +- src/models.ts | 4 + src/providers/index.ts | 5 +- src/providers/kilo-code.ts | 29 ++ src/providers/openclaw.ts | 282 ++++++++++++++++++ src/providers/roo-code.ts | 29 ++ src/providers/vscode-cline-parser.ts | 163 ++++++++++ tests/provider-registry.test.ts | 2 +- tests/providers/kilo-code.test.ts | 62 ++++ tests/providers/openclaw.test.ts | 192 ++++++++++++ tests/providers/roo-code.test.ts | 247 +++++++++++++++ 13 files changed, 1034 insertions(+), 7 deletions(-) create mode 100644 src/providers/kilo-code.ts create mode 100644 src/providers/openclaw.ts create mode 100644 src/providers/roo-code.ts create mode 100644 src/providers/vscode-cline-parser.ts create mode 100644 tests/providers/kilo-code.test.ts create mode 100644 tests/providers/openclaw.test.ts create mode 100644 tests/providers/roo-code.test.ts diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index f943606..24f9d25 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -65,13 +65,13 @@ final class AppStore { /// Switch to a period. Always fetches fresh data so the user never sees stale numbers. func switchTo(period: Period) async { selectedPeriod = period - await refresh(includeOptimize: true) + await refresh(includeOptimize: true, force: true) } /// Switch to a provider filter. Always fetches fresh data so the user never sees stale numbers. func switchTo(provider: ProviderFilter) async { selectedProvider = provider - await refresh(includeOptimize: true) + await refresh(includeOptimize: true, force: true) } private var inFlightKeys: Set = [] @@ -79,11 +79,15 @@ final class AppStore { /// Refresh the currently selected (period, provider) combination. Guards against concurrent /// fetches for the same key so a slow initial request can't overwrite a newer one that /// finished first (which would show stale numbers the user has already moved past). - func refresh(includeOptimize: Bool) async { + /// When `force` is false (background timer), skips the CLI call if the cache is still fresh. + func refresh(includeOptimize: Bool, force: Bool = false) async { let key = currentKey + if !force, cache[key]?.isFresh == true { return } guard !inFlightKeys.contains(key) else { return } inFlightKeys.insert(key) - isLoading = true + if cache[key] == nil { + isLoading = true + } defer { inFlightKeys.remove(key) isLoading = false @@ -228,15 +232,21 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case copilot = "Copilot" case gemini = "Gemini" case kiro = "Kiro" + case kiloCode = "KiloCode" + case openclaw = "OpenClaw" case opencode = "OpenCode" case pi = "Pi" case omp = "OMP" + case rooCode = "Roo Code" var id: String { rawValue } var providerKeys: [String] { switch self { case .cursor: ["cursor", "cursor agent"] + case .rooCode: ["roo-code"] + case .kiloCode: ["kilo-code"] + case .openclaw: ["openclaw"] default: [rawValue.lowercased()] } } @@ -249,10 +259,13 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .cursor: "cursor" case .copilot: "copilot" case .gemini: "gemini" + case .kiloCode: "kilo-code" case .kiro: "kiro" + case .openclaw: "openclaw" case .opencode: "opencode" case .pi: "pi" case .omp: "omp" + case .rooCode: "roo-code" } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index fcf318c..31c03b8 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -93,10 +93,13 @@ extension ProviderFilter { case .cursor: return Theme.categoricalCursor case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) + 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 .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) case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0) case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0) + case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0) } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 86d1d91..f067aa0 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -397,7 +397,7 @@ struct FooterBar: View { .fixedSize() Button { - Task { await store.refresh(includeOptimize: true) } + Task { await store.refresh(includeOptimize: true, force: true) } } label: { Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") .font(.system(size: 11, weight: .medium)) diff --git a/src/models.ts b/src/models.ts index 1e27ee8..083c247 100644 --- a/src/models.ts +++ b/src/models.ts @@ -143,6 +143,8 @@ const BUILTIN_ALIASES: Record = { 'cursor-agent-auto': 'claude-sonnet-4-5', 'copilot-auto': 'claude-sonnet-4-5', 'kiro-auto': 'claude-sonnet-4-5', + 'cline-auto': 'claude-sonnet-4-5', + 'openclaw-auto': 'claude-sonnet-4-5', // Cursor emits dot-version tier-last names 'claude-4.6-sonnet': 'claude-sonnet-4-6', 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', @@ -222,6 +224,8 @@ const autoModelNames: Record = { 'cursor-agent-auto': 'Cursor (auto)', 'copilot-auto': 'Copilot (auto)', 'kiro-auto': 'Kiro (auto)', + 'cline-auto': 'Cline (auto)', + 'openclaw-auto': 'OpenClaw (auto)', } export function getShortModelName(model: string): string { diff --git a/src/providers/index.ts b/src/providers/index.ts index f811459..c17490f 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -2,8 +2,11 @@ import { claude } from './claude.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { gemini } from './gemini.js' +import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' +import { openclaw } from './openclaw.js' import { pi, omp } from './pi.js' +import { rooCode } from './roo-code.js' import type { Provider, SessionSource } from './types.js' let cursorProvider: Provider | null = null @@ -51,7 +54,7 @@ async function loadCursorAgent(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiro, pi, omp] +const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiloCode, kiro, openclaw, pi, omp, rooCode] export async function getAllProviders(): Promise { const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()]) diff --git a/src/providers/kilo-code.ts b/src/providers/kilo-code.ts new file mode 100644 index 0000000..89c9a85 --- /dev/null +++ b/src/providers/kilo-code.ts @@ -0,0 +1,29 @@ +import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'kilocode.kilo-code' + +export function createKiloCodeProvider(overrideDir?: string): Provider { + return { + name: 'kilo-code', + displayName: 'KiloCode', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + return discoverClineTasks(EXTENSION_ID, 'kilo-code', 'KiloCode', overrideDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, 'kilo-code') + }, + } +} + +export const kiloCode = createKiloCodeProvider() diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts new file mode 100644 index 0000000..14575df --- /dev/null +++ b/src/providers/openclaw.ts @@ -0,0 +1,282 @@ +import { readdir, readFile } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const toolNameMap: Record = { + bash: 'Bash', + exec: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', +} + +type OpenClawUsage = { + input: number + output: number + cacheRead: number + cacheWrite: number + totalTokens?: number + cost?: { + total?: number + } +} + +type OpenClawEntry = { + type: string + customType?: string + id?: string + timestamp?: string + provider?: string + modelId?: string + data?: { + provider?: string + modelId?: string + } + message?: { + role?: string + content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record }> + model?: string + provider?: string + usage?: OpenClawUsage + } +} + +type SessionIndex = Record + +function getOpenClawDirs(): string[] { + const home = homedir() + return [ + join(home, '.openclaw', 'agents'), + join(home, '.clawdbot', 'agents'), + join(home, '.moltbot', 'agents'), + join(home, '.moldbot', 'agents'), + ] +} + +function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record }> | undefined): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + if (!content) return { tools, bashCommands } + + for (const block of content) { + if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) { + const mapped = toolNameMap[block.name] ?? block.name + tools.push(mapped) + if (mapped === 'Bash' && block.arguments && typeof block.arguments.command === 'string') { + const cmd = block.arguments.command.split(/\s+/)[0] ?? '' + if (cmd) bashCommands.push(cmd) + } + } + } + return { tools, bashCommands } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const raw = await readSessionFile(source.path) + if (raw === null) return + + const lines = raw.split('\n').filter(l => l.trim()) + let sessionId = '' + let sessionTimestamp = '' + let currentModel = '' + + const calls: { + model: string + usage: OpenClawUsage + tools: string[] + bashCommands: string[] + timestamp: string + userMessage: string + dedupId: string + }[] = [] + + let pendingUserMessage = '' + + for (const line of lines) { + let entry: OpenClawEntry + try { + entry = JSON.parse(line) + } catch { + continue + } + + if (entry.type === 'session') { + sessionId = entry.id ?? basename(source.path, '.jsonl') + sessionTimestamp = entry.timestamp ?? '' + continue + } + + if (entry.type === 'model_change') { + currentModel = entry.modelId ?? currentModel + continue + } + + if (entry.type === 'custom' && entry.customType === 'model-snapshot') { + currentModel = entry.data?.modelId ?? currentModel + continue + } + + if (entry.type !== 'message' || !entry.message) continue + + const msg = entry.message + if (msg.role === 'user') { + if (!pendingUserMessage && Array.isArray(msg.content)) { + const textBlock = msg.content.find(c => c.type === 'text' && c.text) + pendingUserMessage = (textBlock?.text ?? '').slice(0, 500) + } + continue + } + + if (msg.role !== 'assistant') continue + + const model = msg.model ?? currentModel + if (msg.usage) { + const { tools, bashCommands } = extractTools(msg.content) + calls.push({ + model, + usage: msg.usage, + tools, + bashCommands, + timestamp: entry.timestamp ?? sessionTimestamp, + userMessage: pendingUserMessage, + dedupId: entry.id ?? '', + }) + pendingUserMessage = '' + } + } + + if (!sessionId) sessionId = basename(source.path, '.jsonl') + + for (let i = 0; i < calls.length; i++) { + const call = calls[i] + const dedupKey = `openclaw:${sessionId}:${call.dedupId || i}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const u = call.usage + const costFromProvider = u.cost?.total ?? 0 + const costUSD = costFromProvider > 0 + ? costFromProvider + : calculateCost(call.model, u.input, u.output, u.cacheWrite, u.cacheRead, 0) + + const ts = new Date(call.timestamp) + if (isNaN(ts.getTime()) || ts.getTime() < 1_000_000_000_000) continue + + yield { + provider: 'openclaw', + model: call.model || 'openclaw-auto', + inputTokens: u.input, + outputTokens: u.output, + cacheCreationInputTokens: u.cacheWrite, + cacheReadInputTokens: u.cacheRead, + cachedInputTokens: u.cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...new Set(call.tools)], + bashCommands: [...new Set(call.bashCommands)], + timestamp: ts.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: call.userMessage, + sessionId, + } + } + }, + } +} + +async function discoverInDir(agentsDir: string): Promise { + const sources: SessionSource[] = [] + + let agentDirs: string[] + try { + const entries = await readdir(agentsDir, { withFileTypes: true }) + agentDirs = entries.filter(e => e.isDirectory()).map(e => e.name) + } catch { + return sources + } + + for (const agent of agentDirs) { + const sessionsDir = join(agentsDir, agent, 'sessions') + + let indexData: SessionIndex = {} + try { + const indexRaw = await readFile(join(sessionsDir, 'sessions.json'), 'utf-8') + indexData = JSON.parse(indexRaw) + } catch { /* no index, fall back to directory scan */ } + + const seenFiles = new Set() + + for (const entry of Object.values(indexData)) { + if (entry.sessionFile) { + seenFiles.add(entry.sessionFile) + sources.push({ path: entry.sessionFile, project: agent, provider: 'openclaw' }) + } else if (entry.sessionId) { + const filePath = join(sessionsDir, `${entry.sessionId}.jsonl`) + seenFiles.add(filePath) + sources.push({ path: filePath, project: agent, provider: 'openclaw' }) + } + } + + try { + const files = await readdir(sessionsDir) + for (const f of files) { + if (!f.endsWith('.jsonl')) continue + const filePath = join(sessionsDir, f) + if (seenFiles.has(filePath)) continue + sources.push({ path: filePath, project: agent, provider: 'openclaw' }) + } + } catch { /* directory may not exist */ } + } + + return sources +} + +export function createOpenClawProvider(overrideDir?: string): Provider { + return { + name: 'openclaw', + displayName: 'OpenClaw', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + if (overrideDir) return discoverInDir(overrideDir) + const all: SessionSource[] = [] + for (const dir of getOpenClawDirs()) { + const sessions = await discoverInDir(dir) + all.push(...sessions) + } + return all + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const openclaw = createOpenClawProvider() diff --git a/src/providers/roo-code.ts b/src/providers/roo-code.ts new file mode 100644 index 0000000..5ea6ccc --- /dev/null +++ b/src/providers/roo-code.ts @@ -0,0 +1,29 @@ +import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'rooveterinaryinc.roo-cline' + +export function createRooCodeProvider(overrideDir?: string): Provider { + return { + name: 'roo-code', + displayName: 'Roo Code', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, 'roo-code') + }, + } +} + +export const rooCode = createRooCodeProvider() diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts new file mode 100644 index 0000000..d1d26c0 --- /dev/null +++ b/src/providers/vscode-cline-parser.ts @@ -0,0 +1,163 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type UiMessage = { + type?: string + say?: string + text?: string + ts?: number +} + +export function getVSCodeGlobalStoragePath(extensionId: string): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', extensionId) + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', extensionId) + } + return join(homedir(), '.config', 'Code', 'User', 'globalStorage', extensionId) +} + +export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise { + const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId) + const tasksDir = join(baseDir, 'tasks') + const sources: SessionSource[] = [] + + let taskDirs: string[] + try { + taskDirs = await readdir(tasksDir) + } catch { + return sources + } + + for (const taskId of taskDirs) { + const taskDir = join(tasksDir, taskId) + const dirStat = await stat(taskDir).catch(() => null) + if (!dirStat?.isDirectory()) continue + + const uiPath = join(taskDir, 'ui_messages.json') + const uiStat = await stat(uiPath).catch(() => null) + if (!uiStat?.isFile()) continue + + sources.push({ path: taskDir, project: displayName, provider: providerName }) + } + + return sources +} + +const MODEL_TAG_RE = /([^<]+)<\/model>/ + +function extractModelFromHistory(taskDir: string): Promise { + return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8') + .then(raw => { + const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }> + if (!Array.isArray(msgs)) return 'cline-auto' + for (const msg of msgs) { + if (msg.role !== 'user' || !Array.isArray(msg.content)) continue + for (const block of msg.content) { + const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text) + if (match) { + const raw = match[1] + return raw.includes('/') ? raw.split('/').pop()! : raw + } + } + } + return 'cline-auto' + }) + .catch(() => 'cline-auto') +} + +export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string): SessionParser { + return { + async *parse(): AsyncGenerator { + const taskDir = source.path + const taskId = basename(taskDir) + + let uiRaw: string + try { + uiRaw = await readFile(join(taskDir, 'ui_messages.json'), 'utf-8') + } catch { + return + } + + let uiMessages: UiMessage[] + try { + uiMessages = JSON.parse(uiRaw) + } catch { + return + } + + if (!Array.isArray(uiMessages)) return + + const model = await extractModelFromHistory(taskDir) + + let userMessage = '' + for (const msg of uiMessages) { + if (msg.type === 'say' && (msg.say === 'user_feedback' || msg.say === 'text')) { + userMessage = (msg.text ?? '').slice(0, 500) + break + } + } + + const apiReqEntries = uiMessages.filter(m => m.type === 'say' && m.say === 'api_req_started') + + for (const [index, entry] of apiReqEntries.entries()) { + const dedupKey = `${providerName}:${taskId}:${index}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + let tokensIn = 0 + let tokensOut = 0 + let cacheReads = 0 + let cacheWrites = 0 + let cost: number | undefined + + if (entry.text) { + try { + const parsed = JSON.parse(entry.text) as { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + } + tokensIn = parsed.tokensIn ?? 0 + tokensOut = parsed.tokensOut ?? 0 + cacheReads = parsed.cacheReads ?? 0 + cacheWrites = parsed.cacheWrites ?? 0 + cost = parsed.cost + } catch {} + } + + if (tokensIn === 0 && tokensOut === 0) continue + + const timestamp = entry.ts ? new Date(entry.ts).toISOString() : '' + const costUSD = cost ?? calculateCost(model, tokensIn, tokensOut, cacheWrites, cacheReads, 0) + + yield { + provider: providerName, + model, + inputTokens: tokensIn, + outputTokens: tokensOut, + cacheCreationInputTokens: cacheWrites, + cacheReadInputTokens: cacheReads, + cachedInputTokens: cacheReads, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: index === 0 ? userMessage : '', + sessionId: taskId, + } + } + }, + } +} diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 2c7b73e..9680228 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'gemini', 'kiro', 'pi', 'omp']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'roo-code']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/kilo-code.test.ts b/tests/providers/kilo-code.test.ts new file mode 100644 index 0000000..39c1ad2 --- /dev/null +++ b/tests/providers/kilo-code.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { kiloCode, createKiloCodeProvider } from '../../src/providers/kilo-code.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +describe('kilo-code provider - discovery path differentiation', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kilo-code-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers tasks using kilo-code extension path', async () => { + const task = join(tmpDir, 'tasks', 'task-kilo-1') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'ui_messages.json'), JSON.stringify([ + { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 100, tokensOut: 50 }), ts: 1700000000000 }, + ])) + + const provider = createKiloCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('kilo-code') + }) + + it('parses with kilo-code provider name in dedup key', async () => { + const task = join(tmpDir, 'tasks', 'task-kilo-2') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'ui_messages.json'), JSON.stringify([ + { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 200, tokensOut: 100 }), ts: 1700000000000 }, + ])) + + const source = { path: task, project: 'task-kilo-2', provider: 'kilo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiloCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('kilo-code') + expect(calls[0]!.deduplicationKey).toMatch(/^kilo-code:/) + }) +}) + +describe('kilo-code provider - metadata', () => { + it('has correct name and displayName', () => { + expect(kiloCode.name).toBe('kilo-code') + expect(kiloCode.displayName).toBe('KiloCode') + }) + + it('uses different extension ID than roo-code', async () => { + const kiloProvider = createKiloCodeProvider('/tmp/kilo-test') + const sessions = await kiloProvider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts new file mode 100644 index 0000000..d988aac --- /dev/null +++ b/tests/providers/openclaw.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { createOpenClawProvider } from '../../src/providers/openclaw.js' +import { writeFile, mkdir, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +const SESSION_LINES = [ + JSON.stringify({ type: 'session', version: 3, id: 'test-sess-1', timestamp: '2026-04-20T10:00:00.000Z', cwd: '/tmp' }), + JSON.stringify({ type: 'model_change', id: 'mc1', timestamp: '2026-04-20T10:00:01.000Z', provider: 'anthropic', modelId: 'claude-sonnet-4-6' }), + JSON.stringify({ + type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'hello world' }] }, + }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:03.000Z', + message: { + role: 'assistant', model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'Hi!' }], + usage: { input: 500, output: 100, cacheRead: 200, cacheWrite: 50, totalTokens: 850 }, + }, + }), + JSON.stringify({ + type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:05.000Z', + message: { + role: 'assistant', model: 'claude-sonnet-4-6', + content: [ + { type: 'text', text: 'Running command' }, + { type: 'toolCall', name: 'exec', arguments: { command: 'ls -la' } }, + { type: 'toolCall', name: 'read', arguments: { path: '/tmp/x' } }, + { type: 'tool_use', name: 'write', arguments: { path: '/tmp/y' } }, + ], + usage: { input: 600, output: 200, cacheRead: 100, cacheWrite: 0, totalTokens: 900, cost: { total: 0.05 } }, + }, + }), +] + +async function setupFixture(dir: string, agentName: string, sessionId: string, lines: string[]): Promise { + const sessionsDir = join(dir, agentName, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + const filePath = join(sessionsDir, `${sessionId}.jsonl`) + await writeFile(filePath, lines.join('\n')) + return filePath +} + +describe('openclaw provider', () => { + const baseDir = join(tmpdir(), `codeburn-openclaw-test-${Date.now()}`) + + it('discovers sessions in agent directories', async () => { + const dir = join(baseDir, 'discover') + await setupFixture(dir, 'myproject', 'sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + expect(sources.length).toBe(1) + expect(sources[0].provider).toBe('openclaw') + expect(sources[0].project).toBe('myproject') + }) + + it('parses assistant messages with usage', async () => { + const dir = join(baseDir, 'parse') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + expect(calls.length).toBe(2) + expect(calls[0].provider).toBe('openclaw') + expect(calls[0].model).toBe('claude-sonnet-4-6') + expect(calls[0].inputTokens).toBe(500) + expect(calls[0].outputTokens).toBe(100) + expect(calls[0].cacheReadInputTokens).toBe(200) + expect(calls[0].userMessage).toBe('hello world') + expect(calls[0].sessionId).toBe('test-sess-1') + }) + + it('uses cost.total from provider when available', async () => { + const dir = join(baseDir, 'cost') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls[1].costUSD).toBe(0.05) + }) + + it('extracts tools and bash commands', async () => { + const dir = join(baseDir, 'tools') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls[1].tools).toContain('Bash') + expect(calls[1].tools).toContain('Read') + expect(calls[1].tools).toContain('Write') + expect(calls[1].bashCommands).toContain('ls') + }) + + it('deduplicates on re-parse', async () => { + const dir = join(baseDir, 'dedup') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const seen = new Set() + const parser1 = provider.createSessionParser(sources[0], seen) + const calls1: any[] = [] + for await (const c of parser1.parse()) calls1.push(c) + expect(calls1.length).toBe(2) + const parser2 = provider.createSessionParser(sources[0], seen) + const calls2: any[] = [] + for await (const c of parser2.parse()) calls2.push(c) + expect(calls2.length).toBe(0) + }) + + it('reads model from model_change event', async () => { + const lines = [ + JSON.stringify({ type: 'session', id: 'mc-test', timestamp: '2026-04-20T10:00:00.000Z' }), + JSON.stringify({ type: 'model_change', id: 'mc1', modelId: 'gpt-5.5', provider: 'openai' }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', + message: { role: 'assistant', usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }, + }), + ] + const dir = join(baseDir, 'model-change') + await setupFixture(dir, 'proj', 'mc-test', lines) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const c of parser.parse()) calls.push(c) + expect(calls[0].model).toBe('gpt-5.5') + }) + + it('reads model from custom model-snapshot event', async () => { + const lines = [ + JSON.stringify({ type: 'session', id: 'snap-test', timestamp: '2026-04-20T10:00:00.000Z' }), + JSON.stringify({ type: 'custom', customType: 'model-snapshot', data: { modelId: 'glm-5.1:cloud', provider: 'ollama' }, id: 's1' }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', + message: { role: 'assistant', usage: { input: 200, output: 80, cacheRead: 0, cacheWrite: 0 } }, + }), + ] + const dir = join(baseDir, 'snapshot') + await setupFixture(dir, 'proj', 'snap-test', lines) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const c of parser.parse()) calls.push(c) + expect(calls[0].model).toBe('glm-5.1:cloud') + }) + + it('skips entries with invalid timestamps', async () => { + const lines = [ + JSON.stringify({ type: 'session', id: 'bad-ts', timestamp: 'not-a-date' }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: 'also-bad', + message: { role: 'assistant', model: 'test', usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }, + }), + ] + const dir = join(baseDir, 'bad-ts') + await setupFixture(dir, 'proj', 'bad-ts', lines) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const c of parser.parse()) calls.push(c) + expect(calls.length).toBe(0) + }) + + it('tool and model display names work', () => { + const provider = createOpenClawProvider() + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('dispatch_agent')).toBe('Agent') + expect(provider.toolDisplayName('unknown')).toBe('unknown') + expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6') + }) + + it('returns empty for nonexistent directory', async () => { + const provider = createOpenClawProvider('/tmp/nonexistent-openclaw-test') + const sources = await provider.discoverSessions() + expect(sources.length).toBe(0) + }) + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }) + }) +}) diff --git a/tests/providers/roo-code.test.ts b/tests/providers/roo-code.test.ts new file mode 100644 index 0000000..35efee5 --- /dev/null +++ b/tests/providers/roo-code.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { rooCode, createRooCodeProvider } from '../../src/providers/roo-code.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeUiMessages(opts: { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + userMessage?: string + ts?: number +}): string { + const messages: unknown[] = [] + + if (opts.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 }) + } + + const apiData: Record = { + tokensIn: opts.tokensIn ?? 100, + tokensOut: opts.tokensOut ?? 50, + cacheReads: opts.cacheReads ?? 0, + cacheWrites: opts.cacheWrites ?? 0, + } + if (opts.cost !== undefined) apiData.cost = opts.cost + + messages.push({ + type: 'say', + say: 'api_req_started', + text: JSON.stringify(apiData), + ts: opts.ts ?? 1700000001000, + }) + + return JSON.stringify(messages) +} + +function makeApiHistory(opts?: { model?: string }): string { + const modelTag = opts?.model ? `${opts.model}` : '' + const messages = [ + { role: 'user', content: [{ type: 'text', text: `hello\n\n${modelTag}\n` }] }, + { role: 'assistant', content: [{ type: 'text', text: 'response' }] }, + ] + return JSON.stringify(messages) +} + +describe('roo-code provider - parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'roo-code-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses tokens and cost from ui_messages.json', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-001') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ + tokensIn: 200, + tokensOut: 100, + cacheReads: 50, + cacheWrites: 30, + cost: 0.05, + userMessage: 'fix the bug', + })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'task-001', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('roo-code') + expect(call.inputTokens).toBe(200) + expect(call.outputTokens).toBe(100) + expect(call.cacheReadInputTokens).toBe(50) + expect(call.cacheCreationInputTokens).toBe(30) + expect(call.costUSD).toBe(0.05) + expect(call.userMessage).toBe('fix the bug') + expect(call.sessionId).toBe('task-001') + }) + + it('extracts model from api_conversation_history.json', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-002') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory({ model: 'claude-sonnet-4-5' })) + + const source = { path: taskDir, project: 'task-002', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-sonnet-4-5') + }) + + it('falls back to cline-auto when no model indicators', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-003') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + ])) + + const source = { path: taskDir, project: 'task-003', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('cline-auto') + }) + + it('deduplicates across parser runs', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-004') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + + const source = { path: taskDir, project: 'task-004', provider: 'roo-code' } + const seenKeys = new Set() + + const calls1: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('handles missing ui_messages.json gracefully', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-005') + await mkdir(taskDir, { recursive: true }) + + const source = { path: taskDir, project: 'task-005', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('handles invalid JSON gracefully', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-006') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), 'not valid json') + + const source = { path: taskDir, project: 'task-006', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('skips entries with zero tokens', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-007') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify([ + { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 0, tokensOut: 0 }), ts: 1700000000000 }, + ])) + + const source = { path: taskDir, project: 'task-007', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('calculates cost from model when cost field missing', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-008') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 1000, tokensOut: 500 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'task-008', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) +}) + +describe('roo-code provider - discovery', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'roo-code-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers task directories with ui_messages.json', async () => { + const task1 = join(tmpDir, 'tasks', 'task-a') + const task2 = join(tmpDir, 'tasks', 'task-b') + await mkdir(task1, { recursive: true }) + await mkdir(task2, { recursive: true }) + await writeFile(join(task1, 'ui_messages.json'), '[]') + await writeFile(join(task2, 'ui_messages.json'), '[]') + + const provider = createRooCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'roo-code')).toBe(true) + }) + + it('skips tasks without ui_messages.json', async () => { + const task = join(tmpDir, 'tasks', 'task-no-ui') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'api_conversation_history.json'), '[]') + + const provider = createRooCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) + + it('returns empty for nonexistent directory', async () => { + const provider = createRooCodeProvider('/nonexistent/path') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) + +describe('roo-code provider - metadata', () => { + it('has correct name and displayName', () => { + expect(rooCode.name).toBe('roo-code') + expect(rooCode.displayName).toBe('Roo Code') + }) + + it('passes through model display names', () => { + expect(rooCode.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5') + }) + + it('passes through tool display names', () => { + expect(rooCode.toolDisplayName('read_file')).toBe('read_file') + }) +})