From 26ebe75aa142b6b30849e32ce37a78dae10cc2a1 Mon Sep 17 00:00:00 2001 From: Dunccan de Weerdt <46482104+Aeversil@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:59:24 +0200 Subject: [PATCH] Add Droid CLI provider Discovers and parses sessions from ~/.factory/sessions/, reading JSONL message logs and companion settings.json files for token usage tracking. - Discovers sessions by scanning per-cwd subdirectories - Skips internal .factory housekeeping sessions - Extracts tools, bash commands, and user messages from JSONL - Distributes session-level cumulative token counts across calls - Normalizes Droid model wrappers before existing pricing lookup - Derives clean project names from cwd paths - Adds menubar provider filtering for Droid --- mac/Sources/CodeBurnMenubar/AppStore.swift | 2 + .../CodeBurnMenubar/Views/AgentTabStrip.swift | 1 + src/providers/droid.ts | 401 ++++++++++++++++++ src/providers/index.ts | 3 +- tests/provider-registry.test.ts | 2 +- tests/providers/droid.test.ts | 148 +++++++ 6 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 src/providers/droid.ts create mode 100644 tests/providers/droid.test.ts diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 0ded350..eacfa9c 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -230,6 +230,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case codex = "Codex" case cursor = "Cursor" case copilot = "Copilot" + case droid = "Droid" case gemini = "Gemini" case kiro = "Kiro" case kiloCode = "KiloCode" @@ -259,6 +260,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .codex: "codex" case .cursor: "cursor" case .copilot: "copilot" + case .droid: "droid" case .gemini: "gemini" case .kiloCode: "kilo-code" case .kiro: "kiro" diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 477cc89..77b6165 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -92,6 +92,7 @@ extension ProviderFilter { case .codex: return Theme.categoricalCodex case .cursor: return Theme.categoricalCursor case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) + case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/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) diff --git a/src/providers/droid.ts b/src/providers/droid.ts new file mode 100644 index 0000000..d744040 --- /dev/null +++ b/src/providers/droid.ts @@ -0,0 +1,401 @@ +import { readdir, stat, readFile } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { readSessionFile, readSessionLines } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { + Provider, + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +const toolNameMap: Record = { + Read: 'Read', + Create: 'Create', + Edit: 'Edit', + MultiEdit: 'MultiEdit', + LS: 'LS', + Glob: 'Glob', + Grep: 'Grep', + Execute: 'Bash', + AskUser: 'AskUser', + TodoWrite: 'TodoWrite', + Skill: 'Skill', + Task: 'Agent', + WebSearch: 'WebSearch', + FetchUrl: 'FetchUrl', + GenerateDroid: 'GenerateDroid', + ExitSpecMode: 'ExitSpecMode', +} + +type DroidSettings = { + model?: string + tokenUsage?: { + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + } +} + +type DroidContent = { + type: string + text?: string + name?: string + input?: Record +} + +type DroidMessage = { + role: string + content?: DroidContent[] +} + +type DroidJsonlEntry = { + type: string + id?: string + timestamp?: string + message?: DroidMessage + title?: string + cwd?: string +} + +function getFactoryDir(): string { + return process.env['FACTORY_DIR'] ?? join(homedir(), '.factory') +} + + +// Strip Droid-specific wrapper to get the model's display name. +// e.g. "custom:GLM-5.1-[Proxy]-0" -> "GLM-5.1" +// Cost lookup is handled by codeburn's existing calculateCost/getCanonicalName +// which normalizes case and strips date suffixes automatically. +function stripModelPrefix(raw: string): string { + return raw + .replace(/^custom:/, '') + .replace(/\[.*?\]/g, '') + .replace(/-\d+$/, '') + .replace(/-+$/, '') + .replace(/^-/, '') +} + +function parseModelForDisplay(raw: string): string { + const stripped = stripModelPrefix(raw) + const lower = stripped.toLowerCase() + + if (lower.includes('opus')) return getShortModelName(stripped) + if (lower.includes('sonnet')) return getShortModelName(stripped) + if (lower.includes('haiku')) return getShortModelName(stripped) + if (lower.startsWith('gpt-')) return getShortModelName(stripped) + if (lower.startsWith('o3') || lower.startsWith('o4')) return getShortModelName(stripped) + if (lower.startsWith('gemini')) return getShortModelName(stripped) + + return stripped +} + +/** + * Extract meaningful shell command names from a Droid Execute call. + * Droid frequently passes multi-line scripts (python -c "...", heredocs, etc.) + * where splitting on ;/&&/| produces noise tokens like '}', 'await', 'import'. + * Instead, extract only the primary command from each logical line. + */ +function extractDroidBashCommands(command: string): string[] { + if (!command || !command.trim()) return [] + + const firstLine = command.split('\n')[0]!.trim() + return extractBashCommands(firstLine) +} + +function createParser( + source: SessionSource, + seenKeys: Set, +): SessionParser { + return { + async *parse(): AsyncGenerator { + const content = await readSessionFile(source.path) + if (content === null) return + + // Read the companion settings file for token usage + const settingsPath = source.path.replace(/\.jsonl$/, '.settings.json') + let settings: DroidSettings = {} + try { + const raw = await readFile(settingsPath, 'utf-8') + settings = JSON.parse(raw) as DroidSettings + } catch { + // No settings file or parse error + } + + const lines = content.split('\n').filter(l => l.trim()) + let sessionId = '' + let sessionModelDisplay = settings.model ? stripModelPrefix(settings.model) : 'unknown' + let currentUserMessage = '' + + // Collect all assistant messages with their tools + const assistantCalls: Array<{ + id: string + timestamp: string + tools: string[] + bashCommands: string[] + }> = [] + + let pendingTools: string[] = [] + let pendingBashCommands: string[] = [] + + for (const line of lines) { + let entry: DroidJsonlEntry + try { + entry = JSON.parse(line) as DroidJsonlEntry + } catch { + continue + } + + if (entry.type === 'session_start') { + sessionId = entry.id ?? '' + continue + } + + if (entry.type !== 'message' || !entry.message) continue + + const msg = entry.message + + if (msg.role === 'user') { + // Extract user text from content + const texts = (msg.content ?? []) + .filter(c => c.type === 'text' && c.text) + .map(c => c.text!) + .filter(Boolean) + // Skip system-reminder-only messages + const nonSystemTexts = texts.filter(t => !t.startsWith('')) + if (nonSystemTexts.length > 0) { + currentUserMessage = nonSystemTexts.join(' ').slice(0, 500) + } + continue + } + + if (msg.role === 'assistant') { + const toolUses = (msg.content ?? []).filter(c => c.type === 'tool_use') + + for (const tu of toolUses) { + const toolName = tu.name ?? '' + pendingTools.push(toolNameMap[toolName] ?? toolName) + + if (toolName === 'Execute' && tu.input && typeof tu.input['command'] === 'string') { + pendingBashCommands.push(...extractDroidBashCommands(tu.input['command'] as string)) + } + } + + // Check if this assistant message has any text content (non-thinking) + const hasText = (msg.content ?? []).some(c => c.type === 'text' && c.text) + + // Only emit a call entry if there are tools or substantial text + if (pendingTools.length > 0 || hasText) { + assistantCalls.push({ + id: entry.id ?? `msg-${assistantCalls.length}`, + timestamp: entry.timestamp ?? '', + tools: [...pendingTools], + bashCommands: [...pendingBashCommands], + }) + pendingTools = [] + pendingBashCommands = [] + } + continue + } + } + + if (assistantCalls.length === 0) return + + // Distribute session-level token usage across calls + const totalTokens = settings.tokenUsage + if (!totalTokens) return + + const totalInput = totalTokens.inputTokens ?? 0 + const totalOutput = totalTokens.outputTokens ?? 0 + const totalCacheCreation = totalTokens.cacheCreationTokens ?? 0 + const totalCacheRead = totalTokens.cacheReadTokens ?? 0 + const totalThinking = totalTokens.thinkingTokens ?? 0 + const numCalls = assistantCalls.length + + // Distribute evenly across calls + const inputPerCall = Math.floor(totalInput / numCalls) + const outputPerCall = Math.floor(totalOutput / numCalls) + const cacheCreationPerCall = Math.floor(totalCacheCreation / numCalls) + const cacheReadPerCall = Math.floor(totalCacheRead / numCalls) + const thinkingPerCall = Math.floor(totalThinking / numCalls) + + for (let i = 0; i < assistantCalls.length; i++) { + const call = assistantCalls[i] + + // Assign remainder to the last call + const isLast = i === assistantCalls.length - 1 + const inputTokens = isLast + ? totalInput - inputPerCall * (numCalls - 1) + : inputPerCall + const outputTokens = isLast + ? totalOutput - outputPerCall * (numCalls - 1) + : outputPerCall + const cacheCreationTokens = isLast + ? totalCacheCreation - cacheCreationPerCall * (numCalls - 1) + : cacheCreationPerCall + const cacheReadTokens = isLast + ? totalCacheRead - cacheReadPerCall * (numCalls - 1) + : cacheReadPerCall + const thinkingTokens = isLast + ? totalThinking - thinkingPerCall * (numCalls - 1) + : thinkingPerCall + + const dedupKey = `droid:${sessionId}:${call.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const costUSD = calculateCost( + sessionModelDisplay.toLowerCase(), + inputTokens, + outputTokens + thinkingTokens, + cacheCreationTokens, + cacheReadTokens, + 0, + ) + + // Use the call's timestamp, or session_start timestamp + const timestamp = call.timestamp || '' + + yield { + provider: 'droid', + model: sessionModelDisplay, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: cacheReadTokens, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + costUSD, + tools: call.tools, + bashCommands: call.bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: i === 0 ? currentUserMessage : '', + sessionId, + } + } + }, + } +} + +function isInternalSession(cwd: string, factoryDir: string): boolean { + // Skip sessions whose cwd is the .factory directory itself (internal housekeeping) + const normalized = cwd.replace(/\/+$/, '') + return normalized === factoryDir +} + +function deriveProjectName(cwd: string): string { + const normalized = cwd.replace(/\/+$/, '') + const home = homedir() + + // Strip home directory prefix + let relative = normalized.startsWith(home) + ? normalized.slice(home.length).replace(/^\/+/, '') + : normalized.replace(/^\/+/, '') + + if (!relative) relative = '~' + + // Walk from the right: use the "projects/" segment if present, + // otherwise the last meaningful path component. + const parts = relative.split('/') + const projectsIdx = parts.lastIndexOf('projects') + if (projectsIdx !== -1 && projectsIdx + 1 < parts.length) { + return parts.slice(projectsIdx + 1).join('/') + } + + return parts.join('/') +} + +async function readFirstJsonlLine(filePath: string): Promise { + for await (const line of readSessionLines(filePath)) { + return line + } + return null +} + +async function discoverSessionsInDir( + sessionsDir: string, + factoryDir: string, +): Promise { + const sources: SessionSource[] = [] + + let entries: string[] + try { + entries = await readdir(sessionsDir) + } catch { + return sources + } + + for (const entry of entries) { + const subDir = join(sessionsDir, entry) + const s = await stat(subDir).catch(() => null) + if (!s?.isDirectory()) continue + + const files = await readdir(subDir).catch(() => [] as string[]) + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const filePath = join(subDir, file) + + const firstLine = await readFirstJsonlLine(filePath) + if (!firstLine?.trim()) continue + + let startEntry: DroidJsonlEntry + try { + startEntry = JSON.parse(firstLine) as DroidJsonlEntry + } catch { + continue + } + + if (startEntry.type !== 'session_start') continue + + const cwd = startEntry.cwd ?? entry + if (isInternalSession(cwd, factoryDir)) continue + + sources.push({ + path: filePath, + project: deriveProjectName(cwd), + provider: 'droid', + }) + } + } + + return sources +} + +export function createDroidProvider(factoryDir?: string): Provider { + const base = factoryDir ?? getFactoryDir() + const sessionsDir = join(base, 'sessions') + + return { + name: 'droid', + displayName: 'Droid', + + modelDisplayName(model: string): string { + return parseModelForDisplay(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(sessionsDir, base) + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + ): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const droid = createDroidProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index eed7dcf..a754ada 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,7 @@ import { claude } from './claude.js' import { codex } from './codex.js' import { copilot } from './copilot.js' +import { droid } from './droid.js' import { gemini } from './gemini.js' import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' @@ -55,7 +56,7 @@ async function loadCursorAgent(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()]) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 583327e..5780d90 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', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/droid.test.ts b/tests/providers/droid.test.ts new file mode 100644 index 0000000..c312b45 --- /dev/null +++ b/tests/providers/droid.test.ts @@ -0,0 +1,148 @@ +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 { createDroidProvider } from '../../src/providers/droid.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let factoryDir: string + +async function writeSession(opts: { + projectDir?: string + sessionId?: string + lines?: unknown[] + settings?: unknown + subdir?: string +}): Promise { + const sessionId = opts.sessionId ?? 'session-1' + const projectDir = opts.projectDir ?? '/tmp/my-project' + const subdir = opts.subdir ?? '-tmp-my-project' + const dir = join(factoryDir, 'sessions', subdir) + await mkdir(dir, { recursive: true }) + const jsonlPath = join(dir, `${sessionId}.jsonl`) + const lines = opts.lines ?? [ + { type: 'session_start', id: sessionId, cwd: projectDir, title: 'Test session' }, + { type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'build this' }] } }, + { type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } }, + ] + await writeFile(jsonlPath, lines.map(line => JSON.stringify(line)).join('\n')) + + if (opts.settings !== undefined) { + await writeFile(join(dir, `${sessionId}.settings.json`), JSON.stringify(opts.settings)) + } + + return jsonlPath +} + +async function parseAll(filePath: string, seen = new Set()): Promise { + const provider = createDroidProvider(factoryDir) + const parser = provider.createSessionParser({ path: filePath, project: 'proj', provider: 'droid' }, seen) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('droid provider', () => { + beforeEach(async () => { + factoryDir = await mkdtemp(join(tmpdir(), 'codeburn-droid-test-')) + }) + + afterEach(async () => { + await rm(factoryDir, { recursive: true, force: true }) + }) + + it('discovers Droid JSONL sessions', async () => { + await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } }) + + const provider = createDroidProvider(factoryDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('droid') + expect(sessions[0]!.path.endsWith('session-1.jsonl')).toBe(true) + }) + + it('parses calls and distributes session-level token usage', async () => { + const path = await writeSession({ + lines: [ + { type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' }, + { type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'x' }, { type: 'text', text: 'build this' }] } }, + { type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } }, + { type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] } }, + ], + settings: { model: 'custom:gpt-5-[Proxy]-0', tokenUsage: { inputTokens: 101, outputTokens: 51, cacheCreationTokens: 7, cacheReadTokens: 11, thinkingTokens: 5 } }, + }) + + const calls = await parseAll(path) + + expect(calls).toHaveLength(2) + expect(calls[0]!.provider).toBe('droid') + expect(calls[0]!.model).toBe('gpt-5') + expect(calls[0]!.inputTokens).toBe(50) + expect(calls[1]!.inputTokens).toBe(51) + expect(calls[0]!.outputTokens).toBe(25) + expect(calls[1]!.outputTokens).toBe(26) + expect(calls[0]!.cacheReadInputTokens).toBe(5) + expect(calls[1]!.cacheReadInputTokens).toBe(6) + expect(calls[0]!.userMessage).toBe('build this') + expect(calls[0]!.sessionId).toBe('session-1') + }) + + it('extracts tools and meaningful bash command names', async () => { + const path = await writeSession({ + lines: [ + { type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' }, + { type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Execute', input: { command: "python3 - <<'PY'\nimport os\n}\nPY" } }, + { type: 'tool_use', name: 'Read', input: { file_path: '/tmp/a' } }, + { type: 'tool_use', name: 'Task', input: { prompt: 'do work' } }, + ] } }, + ], + settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } }, + }) + + const calls = await parseAll(path) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Agent']) + expect(calls[0]!.bashCommands).toContain('python3') + expect(calls[0]!.bashCommands).not.toContain('import') + expect(calls[0]!.bashCommands).not.toContain('}') + }) + + it('deduplicates calls by session and message id', async () => { + const path = await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } }) + const seen = new Set() + + expect(await parseAll(path, seen)).toHaveLength(1) + expect(await parseAll(path, seen)).toHaveLength(0) + }) + + it('strips Droid model wrappers for display', () => { + const provider = createDroidProvider(factoryDir) + expect(provider.modelDisplayName('custom:GLM-5.1-[Proxy]-0')).toBe('GLM-5.1') + expect(provider.modelDisplayName('custom:claude-sonnet-4-6-1')).toBe('Sonnet 4.6') + }) + + it('returns no calls when settings are missing', async () => { + const path = await writeSession({}) + expect(await parseAll(path)).toHaveLength(0) + }) + + it('skips internal .factory sessions during discovery', async () => { + await writeSession({ projectDir: factoryDir, subdir: '-internal', settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } }) + + const provider = createDroidProvider(factoryDir) + expect(await provider.discoverSessions()).toHaveLength(0) + }) + + it('returns no calls for empty sessions', async () => { + const path = await writeSession({ + lines: [{ type: 'session_start', id: 'empty', cwd: '/tmp/my-project' }], + settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } }, + }) + + expect(await parseAll(path)).toHaveLength(0) + }) +})