From cb8f6677e147b0c2008cff15fa285ef085fc489c Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 2 May 2026 09:56:21 -0700 Subject: [PATCH] Add Goose provider, fix Codex fork dedup Goose: read token usage from ~/.local/share/goose/sessions/sessions.db. Lazy-loaded, zero overhead for non-Goose users. Codex: use sessionId instead of file path in dedup key so forked sessions sharing the same session_id don't double-count tokens. --- src/providers/codex.ts | 2 +- src/providers/goose.ts | 274 +++++++++++++++++++++++++++++++++++++++++ src/providers/index.ts | 22 +++- 3 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/providers/goose.ts diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 5a5e824..2c1f53a 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -299,7 +299,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const model = resolveModel(entry.payload, sessionModel) const timestamp = entry.timestamp ?? '' - const dedupKey = `codex:${source.path}:${timestamp}:${cumulativeTotal}` + const dedupKey = `codex:${sessionId}:${timestamp}:${cumulativeTotal}` if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) diff --git a/src/providers/goose.ts b/src/providers/goose.ts new file mode 100644 index 0000000..b46fa13 --- /dev/null +++ b/src/providers/goose.ts @@ -0,0 +1,274 @@ +import { join } from 'path' +import { homedir, platform } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type SessionRow = { + id: string + name: string + working_dir: string | null + created_at: string | null + updated_at: string | null + accumulated_input_tokens: number | null + accumulated_output_tokens: number | null + provider_name: string | null + model_config_json: string | null +} + +type ModelConfig = { + model_name?: string + reasoning?: boolean +} + +type MessageRow = { + message_id: string + role: string + content_json: string + created_timestamp: number +} + +type ContentItem = { + type: string + toolCall?: { value?: { name?: string; arguments?: Record } } +} + +const toolNameMap: Record = { + developer__shell: 'Bash', + developer__text_editor: 'Edit', + developer__read_file: 'Read', + developer__write_file: 'Write', + developer__list_directory: 'LS', + developer__search_files: 'Grep', + computercontroller__shell: 'Bash', +} + +function sanitize(dir: string): string { + return dir.replace(/^\//, '').replace(/\//g, '-') +} + +function getDbPath(): string { + const root = process.env['GOOSE_PATH_ROOT'] + if (root) return join(root, 'data', 'sessions', 'sessions.db') + + const p = platform() + if (p === 'darwin' || p === 'linux') { + const base = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return join(base, 'goose', 'sessions', 'sessions.db') + } + return join(homedir(), 'AppData', 'Roaming', 'Block', 'goose', 'sessions', 'sessions.db') +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions LIMIT 1") + db.query<{ cnt: number }>("SELECT COUNT(*) as cnt FROM messages LIMIT 1") + return true + } catch { + return false + } +} + +function parseModelConfig(raw: string | null): ModelConfig { + if (!raw) return {} + try { + return JSON.parse(raw) as ModelConfig + } catch { + return {} + } +} + +function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + const seen = new Set() + + try { + const rows = db.query<{ content_json: string }>( + "SELECT content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%'", + [sessionId], + ) + + for (const row of rows) { + let items: ContentItem[] + try { + items = JSON.parse(row.content_json) as ContentItem[] + } catch { + continue + } + for (const item of items) { + if (item.type !== 'toolRequest') continue + const rawName = item.toolCall?.value?.name ?? '' + if (!rawName) continue + const mapped = toolNameMap[rawName] ?? rawName.split('__').pop() ?? rawName + if (!seen.has(mapped)) { + seen.add(mapped) + tools.push(mapped) + } + if (mapped === 'Bash') { + const cmd = item.toolCall?.value?.arguments?.command + if (typeof cmd === 'string') { + const first = cmd.split(/\s+/)[0] ?? '' + if (first && !bashCommands.includes(first)) bashCommands.push(first) + } + } + } + } + } catch { /* best-effort */ } + + return { tools, bashCommands } +} + +function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string { + try { + const rows = db.query<{ content_json: string }>( + "SELECT content_json FROM messages WHERE session_id = ? AND role = 'user' ORDER BY created_timestamp ASC LIMIT 1", + [sessionId], + ) + if (rows.length === 0) return '' + const items = JSON.parse(rows[0]!.content_json) as ContentItem[] + const text = items.find(i => i.type === 'text') as { text?: string } | undefined + return (text?.text ?? '').slice(0, 500) + } catch { + return '' + } +} + +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 Goose database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + if (!validateSchema(db)) return + + const rows = db.query( + 'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, model_config_json FROM sessions WHERE id = ?', + [sessionId], + ) + if (rows.length === 0) return + + const session = rows[0]! + const inputTokens = session.accumulated_input_tokens ?? 0 + const outputTokens = session.accumulated_output_tokens ?? 0 + if (inputTokens === 0 && outputTokens === 0) return + + const dedupKey = `goose:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const config = parseModelConfig(session.model_config_json) + const model = config.model_name ?? 'unknown' + const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0) + + const { tools, bashCommands } = extractToolsFromMessages(db, sessionId) + const userMessage = getFirstUserMessage(db, sessionId) + + const raw = session.updated_at || session.created_at || '' + let ts = new Date(raw) + if (isNaN(ts.getTime())) ts = new Date(raw + 'Z') + if (isNaN(ts.getTime())) ts = new Date() + + yield { + provider: 'goose', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: ts.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage, + 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, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, model_config_json FROM sessions ORDER BY updated_at DESC', + ) + + return rows + .filter(r => (r.accumulated_input_tokens ?? 0) > 0 || (r.accumulated_output_tokens ?? 0) > 0) + .map(row => ({ + path: `${dbPath}:${row.id}`, + project: row.working_dir ? sanitize(row.working_dir) : 'goose', + provider: 'goose', + })) + } catch { + return [] + } finally { + db.close() + } +} + +const modelDisplayNames: Record = { + 'gpt-5.5': 'GPT-5.5', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-4o-mini': 'GPT-4o Mini', +} + +export function createGooseProvider(): Provider { + return { + name: 'goose', + displayName: 'Goose', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbPath = getDbPath() + return discoverFromDb(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const goose = createGooseProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 182c219..5cf8092 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -26,6 +26,21 @@ async function loadAntigravity(): Promise { } } +let gooseProvider: Provider | null = null +let gooseLoadAttempted = false + +async function loadGoose(): Promise { + if (gooseLoadAttempted) return gooseProvider + gooseLoadAttempted = true + try { + const { goose } = await import('./goose.js') + gooseProvider = goose + return goose + } catch { + return null + } +} + let cursorProvider: Provider | null = null let cursorLoadAttempted = false @@ -74,9 +89,10 @@ 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, cursor, opencode, cursorAgent] = await Promise.all([loadAntigravity(), loadCursor(), loadOpenCode(), loadCursorAgent()]) + const [ag, gs, cursor, opencode, cursorAgent] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent()]) 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) @@ -103,6 +119,10 @@ export async function getProvider(name: string): Promise { const ag = await loadAntigravity() return ag ?? undefined } + if (name === 'goose') { + const gs = await loadGoose() + return gs ?? undefined + } if (name === 'cursor') { const cursor = await loadCursor() return cursor ?? undefined