mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-18 23:37:13 +00:00
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.
This commit is contained in:
parent
8c845253c2
commit
cb8f6677e1
3 changed files with 296 additions and 2 deletions
|
|
@ -299,7 +299,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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)
|
||||
|
|
|
|||
274
src/providers/goose.ts
Normal file
274
src/providers/goose.ts
Normal file
|
|
@ -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<string, unknown> } }
|
||||
}
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
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<string>()
|
||||
|
||||
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<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
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<SessionRow>(
|
||||
'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<SessionSource[]> {
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(dbPath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = db.query<SessionRow>(
|
||||
'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<string, string> = {
|
||||
'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<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const dbPath = getDbPath()
|
||||
return discoverFromDb(dbPath)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const goose = createGooseProvider()
|
||||
|
|
@ -26,6 +26,21 @@ async function loadAntigravity(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
let gooseProvider: Provider | null = null
|
||||
let gooseLoadAttempted = false
|
||||
|
||||
async function loadGoose(): Promise<Provider | null> {
|
||||
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<Provider | null> {
|
|||
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
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<Provider | undefined> {
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue