diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f2d4db..564b7a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased ### Added (CLI) +- **Hermes Agent provider.** Track token usage, cost, and tool breakdowns + for Hermes Agent sessions. Reads from `~/.hermes/state.db` and per-profile + databases. Supports session-level accounting with actual/estimated costs + from Hermes, falling back to CodeBurn's model pricing table. - **Tooling breakdowns in dashboard and menubar.** New panels showing core tools, MCP servers, and shell command usage per session and across periods. - **File-aware retry detection with typed ToolCall.** One-shot rate now tracks @@ -13,6 +17,10 @@ tool-name-based detection. ### Fixed (CLI) +- **Hermes profile and Windows project parsing.** Hermes Agent state databases + now only treat exact `profiles//state.db` paths as named profiles, + avoiding sibling-directory prefix collisions, and `Current working directory:` + inference recognizes Windows drive paths. - **Codex 100% one-shot rate.** Codex function_call arguments are JSON strings, not objects, and `patch_apply_end` stores file paths in `changes` object keys. Both are now parsed correctly. diff --git a/docs/providers/README.md b/docs/providers/README.md index 0637e771..f1e54f29 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -16,6 +16,7 @@ For the architectural picture, see `../architecture.md`. | [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | | [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | | [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | +| [Hermes Agent](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` | | [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` | | [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` | | [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` | diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md new file mode 100644 index 00000000..9300ef94 --- /dev/null +++ b/docs/providers/hermes.md @@ -0,0 +1,67 @@ +# Hermes Agent + +Hermes Agent CLI profiles. + +- **Source:** `src/providers/hermes.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/hermes.test.ts` + +## Where it reads from + +| Source | Path | +|---|---| +| Default Hermes profile | `$HERMES_HOME/state.db` if set, otherwise `~/.hermes/state.db` | +| Named Hermes profiles | `$HERMES_HOME/profiles//state.db` | + +## Storage format + +SQLite. The provider reads Hermes' aggregate `sessions` token/cost counters and the matching `messages` rows for user prompt and tool-call context. + +## Parser + +Hermes stores durable token accounting at the session level, so CodeBurn emits one parsed call per Hermes session instead of one call per LLM API request. The call contains the aggregate session totals: + +- input tokens +- output tokens +- cache-read tokens +- cache-write tokens +- reasoning tokens +- actual or estimated cost when Hermes recorded one + +If Hermes recorded no positive cost, CodeBurn falls back to its normal model pricing table. + +## Project grouping + +Discovery groups sessions by Hermes profile (`default`, `coder`, `analytics`, etc.). When a session message includes a clean `Current working directory: /path` line, parsing can attach that project path so CodeBurn can canonicalize worktrees. The parser deliberately ignores quoted or escaped prompt text that merely contains the phrase `Current working directory:`. + +## Tool mapping + +Hermes `tool_calls` are normalized to CodeBurn display names where possible: + +- `terminal` -> `Bash` +- `read_file` -> `Read` +- `write_file` -> `Write` +- `patch` -> `Edit` +- `search_files` -> `Grep` +- browser tools -> `Browser` +- web tools -> `WebSearch` / `WebFetch` +- skill tools -> `Skill` + +Terminal command arguments are exposed as `bashCommands` for CodeBurn's command breakdowns. + +## Caching + +The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is included in the provider environment fingerprint so changing the runtime home invalidates stale cached results. + +## Quirks + +- The provider is aggregate-first because Hermes' stable accounting lives in `sessions`. Do not infer per-turn usage from message text. +- Source paths are encoded as `#hermes-session=` so SQLite paths containing `:` remain safe. +- SQLite schema checks are intentionally light: if the expected `sessions` or `messages` columns are absent, the DB is skipped. + +## When fixing a bug here + +1. Reproduce against a real Hermes `state.db` or a minimal SQLite fixture. +2. Run `npm test -- tests/providers/hermes.test.ts --run`. +3. For local smoke testing, use an isolated cache directory, for example: + `CODEBURN_CACHE_DIR=/tmp/codeburn-hermes-cache node --import tsx -e "import { parseAllSessions } from './src/parser.ts'; console.log(await parseAllSessions(undefined, 'hermes'))"`. diff --git a/src/main.ts b/src/main.ts index 48071c8e..b2e9a33a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -172,7 +172,11 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) - const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalReasoning = sessions.reduce((s, sess) => s + sess.totalReasoningTokens, 0) + // CodeBurn reports output as billable completion-side tokens. Providers keep + // reasoning separate, and their cost math prices it as output, so JSON + // presentation combines raw output + reasoning while exposing reasoning too. + const totalOutput = sessions.reduce((s, sess) => s + billableOutputTokens(sess.totalOutputTokens, sess.totalReasoningTokens), 0) const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write @@ -235,15 +239,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: sessions: p.sessions.length, })) - const modelMap: Record = {} + const modelMap: Record = {} const modelEfficiency = aggregateModelEfficiency(projects) for (const sess of sessions) { for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } + if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } modelMap[model].calls += d.calls modelMap[model].cost += d.costUSD modelMap[model].inputTokens += d.tokens.inputTokens - modelMap[model].outputTokens += d.tokens.outputTokens + modelMap[model].outputTokens += billableOutputTokens(d.tokens.outputTokens, d.tokens.reasoningTokens) + modelMap[model].reasoningTokens += d.tokens.reasoningTokens modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens } @@ -335,6 +340,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: tokens: { input: totalInput, output: totalOutput, + reasoning: totalReasoning, cacheRead: totalCacheRead, cacheWrite: totalCacheWrite, }, @@ -352,6 +358,10 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: } } +function billableOutputTokens(outputTokens: number, reasoningTokens: number): number { + return outputTokens + reasoningTokens +} + program .command('report', { isDefault: true }) .description('Interactive usage dashboard') @@ -399,6 +409,11 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData const catTotals: Record = {} const modelTotals: Record = {} let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 + // TODO: outputTokens here is raw (excludes reasoning tokens). buildJsonReport + // uses billableOutputTokens() to combine output + reasoning for display. This + // TUI path and the session-level output at line ~635 should do the same once + // totalReasoningTokens is available on SessionSummary. Pre-existing gap that + // also affects Claude reasoning tokens. for (const sess of sessions) { inputTokens += sess.totalInputTokens diff --git a/src/parser.ts b/src/parser.ts index cda0a688..8f4c3532 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1205,6 +1205,7 @@ function buildSessionSummary( let totalCost = 0 let totalInput = 0 let totalOutput = 0 + let totalReasoning = 0 let totalCacheRead = 0 let totalCacheWrite = 0 let apiCalls = 0 @@ -1242,6 +1243,7 @@ function buildSessionSummary( totalCost += call.costUSD totalInput += call.usage.inputTokens totalOutput += call.usage.outputTokens + totalReasoning += call.usage.reasoningTokens totalCacheRead += call.usage.cacheReadInputTokens totalCacheWrite += call.usage.cacheCreationInputTokens apiCalls++ @@ -1258,6 +1260,7 @@ function buildSessionSummary( modelBreakdown[modelKey].costUSD += call.costUSD modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens + modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens @@ -1293,6 +1296,7 @@ function buildSessionSummary( totalCostUSD: totalCost, totalInputTokens: totalInput, totalOutputTokens: totalOutput, + totalReasoningTokens: totalReasoning, totalCacheReadTokens: totalCacheRead, totalCacheWriteTokens: totalCacheWrite, apiCalls, @@ -1600,7 +1604,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'hermes') ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts new file mode 100644 index 00000000..89c7c7cd --- /dev/null +++ b/src/providers/hermes.ts @@ -0,0 +1,457 @@ +import { readdir, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ToolCall } from '../types.js' + +type HermesSessionRow = { + id: string + source: string | null + model: string | null + billing_provider: string | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + reasoning_tokens: number | null + estimated_cost_usd: number | null + actual_cost_usd: number | null + api_call_count: number | null + tool_call_count: number | null + started_at: number | null + ended_at: number | null + title: string | null +} + +type HermesMessageRow = { + id: number | null + role: string + content: string | null + tool_calls: string | null + tool_name: string | null + timestamp: number | null +} + +type HermesToolCall = { + function?: { + name?: string + arguments?: string + } +} + +type ProfileDb = { + dbPath: string + profile: string +} + +type TableInfoRow = { + name: string +} + +type TableColumn = keyof HermesSessionRow | keyof HermesMessageRow + +const toolNameMap: Record = { + terminal: 'Bash', + execute_code: 'CodeExecution', + read_file: 'Read', + search_files: 'Grep', + write_file: 'Write', + patch: 'Edit', + browser_navigate: 'Browser', + browser_click: 'Browser', + browser_type: 'Browser', + browser_press: 'Browser', + browser_scroll: 'Browser', + browser_snapshot: 'Browser', + browser_vision: 'Vision', + browser_console: 'Browser', + browser_get_images: 'Browser', + web_search: 'WebSearch', + web_extract: 'WebFetch', + delegate_task: 'Agent', + vision_analyze: 'Vision', + process: 'Bash', + todo: 'TodoWrite', + skill_view: 'Skill', + skill_manage: 'Skill', + skills_list: 'Skill', + memory: 'Memory', + session_search: 'SessionSearch', +} + +function getHermesHome(override?: string): string { + return override ?? process.env['HERMES_HOME'] ?? join(homedir(), '.hermes') +} + +function sanitizeProject(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return 'hermes' + return trimmed.replace(/^[/\\]/, '').replace(/[:/\\]/g, '-') +} + +function parseProfileName(dbPath: string, hermesHome: string): string { + const profilesDir = join(hermesHome, 'profiles') + const dir = dirname(dbPath) + if (dirname(dir) === profilesDir) return basename(dir) + return 'default' +} + +async function findStateDbs(hermesHome: string): Promise { + const dbs: ProfileDb[] = [] + const rootDb = join(hermesHome, 'state.db') + const rootStat = await stat(rootDb).catch(() => null) + if (rootStat?.isFile()) dbs.push({ dbPath: rootDb, profile: 'default' }) + + const profilesDir = join(hermesHome, 'profiles') + const profiles = await readdir(profilesDir, { withFileTypes: true }).catch(() => []) + for (const entry of profiles) { + if (!entry.isDirectory()) continue + const dbPath = join(profilesDir, entry.name, 'state.db') + const s = await stat(dbPath).catch(() => null) + if (s?.isFile()) dbs.push({ dbPath, profile: entry.name }) + } + return dbs +} + +function encodeSourcePath(dbPath: string, sessionId: string): string { + return `${dbPath}#hermes-session=${encodeURIComponent(sessionId)}` +} + +function decodeSourcePath(path: string): { dbPath: string; sessionId: string } | null { + const marker = '#hermes-session=' + const idx = path.lastIndexOf(marker) + if (idx === -1) return null + return { + dbPath: path.slice(0, idx), + sessionId: decodeURIComponent(path.slice(idx + marker.length)), + } +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query('SELECT session_id, role, content, tool_calls FROM messages LIMIT 1') + const columns = getSessionColumns(db) + return columns.has('id') && columns.has('input_tokens') && columns.has('output_tokens') + } catch (err) { + if (isSqliteBusyError(err)) throw err + return false + } +} + +function getSessionColumns(db: SqliteDatabase): Set { + return new Set(db.query('PRAGMA table_info(sessions)').map(row => row.name)) +} + +function numberColumn(columns: Set, name: TableColumn): string { + return columns.has(name) ? `coalesce(${name}, 0) AS ${name}` : `0 AS ${name}` +} + +function nullableColumn(columns: Set, name: TableColumn): string { + return columns.has(name) ? name : `NULL AS ${name}` +} + +function getMessageColumns(db: SqliteDatabase): Set { + return new Set(db.query('PRAGMA table_info(messages)').map(row => row.name)) +} + +function usageExpression(columns: Set): string { + const usageColumns: Array = [ + 'input_tokens', + 'output_tokens', + 'cache_read_tokens', + 'cache_write_tokens', + 'reasoning_tokens', + ] + const parts = usageColumns + .filter(name => columns.has(name)) + .map(name => `coalesce(${name}, 0)`) + return parts.length > 0 ? parts.join(' + ') : '0' +} + +function parseTimestamp(raw: number | null): string { + if (!raw) return '' + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function firstUserMessage(messages: HermesMessageRow[]): string { + const msg = messages.find(m => m.role === 'user' && typeof m.content === 'string' && m.content.trim().length > 0) + return Array.from(msg?.content ?? '').slice(0, 500).join('') +} + +function mapToolName(raw: string): string { + // Composio MCP tools are matched first — the generic mcp_ prefix on line + // below would also match composio names, so order matters here. + if (raw.startsWith('mcp_composio_')) return 'MCP' + if (raw.startsWith('mcp_') || raw.startsWith('mcp__')) return raw + if (raw.startsWith('browser_')) return 'Browser' + return toolNameMap[raw] ?? raw +} + +function parseToolCalls(raw: string | null): HermesToolCall[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? parsed as HermesToolCall[] : [] + } catch { + return [] + } +} + +function collectTools(messages: HermesMessageRow[]): { tools: string[]; toolSequence: ToolCall[][]; bashCommands: string[] } { + const tools: string[] = [] + const toolSequence: ToolCall[][] = [] + const bashCommands: string[] = [] + + for (const msg of messages) { + if (msg.role === 'assistant') { + const currentTurnTools: ToolCall[] = [] + for (const call of parseToolCalls(msg.tool_calls)) { + const rawName = call.function?.name ?? '' + if (!rawName) continue + const mapped = mapToolName(rawName) + tools.push(mapped) + const toolCall: ToolCall = { tool: mapped } + const rawArgs = call.function?.arguments + if (rawArgs) { + try { + const args = JSON.parse(rawArgs) as Record + const file = args['path'] ?? args['file_path'] + if (typeof file === 'string') toolCall.file = file + const command = args['command'] + if (typeof command === 'string') { + toolCall.command = command + bashCommands.push(command) + } + } catch { + // Ignore malformed arguments from historical sessions. + } + } + currentTurnTools.push(toolCall) + } + if (currentTurnTools.length > 0) { + toolSequence.push(currentTurnTools) + } + } else if (msg.role === 'tool' && msg.tool_name) { + tools.push(mapToolName(msg.tool_name)) + } + } + + return { + tools: [...new Set(tools)], + toolSequence: toolSequence.length > 0 ? toolSequence : [], + bashCommands, + } +} + +function inferProject(messages: HermesMessageRow[], fallback: string): { project: string; projectPath?: string } { + const cwdPattern = /^Current working directory:\s*([a-zA-Z]:\\[^\r\n`"]+|\/[^\r\n`"\\]+)/m + for (const msg of messages) { + const text = msg.content ?? '' + const match = cwdPattern.exec(text) + if (match?.[1]) { + const projectPath = match[1].trim() + return { project: sanitizeProject(projectPath), projectPath } + } + } + return { project: fallback } +} + +async function discoverFromDb(dbPath: string, profile: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + if (!validateSchema(db)) return [] + const columns = getSessionColumns(db) + const usage = usageExpression(columns) + const orderBy = columns.has('started_at') ? 'started_at DESC' : 'id DESC' + const rows = db.query( + `SELECT id, + ${nullableColumn(columns, 'title')}, + ${numberColumn(columns, 'input_tokens')}, + ${numberColumn(columns, 'output_tokens')}, + ${numberColumn(columns, 'cache_read_tokens')}, + ${numberColumn(columns, 'cache_write_tokens')}, + ${numberColumn(columns, 'reasoning_tokens')} + FROM sessions + WHERE ${usage} > 0 + ORDER BY ${orderBy}`, + ) + + return rows.map(row => ({ + path: encodeSourcePath(dbPath, row.id), + project: sanitizeProject(profile), + provider: 'hermes', + })) + } catch (err) { + process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) + return [] + } finally { + db.close() + } +} + +function createParser(source: SessionSource, seenKeys: Set, hermesHome: string): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const decoded = decodeSourcePath(source.path) + if (!decoded) return + const profile = parseProfileName(decoded.dbPath, hermesHome) + + let db: SqliteDatabase + try { + db = openDatabase(decoded.dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Hermes database: ${err instanceof Error ? err.message : err}\n`) + return + } + + let result: ParsedProviderCall | undefined + try { + if (!validateSchema(db)) return + const columns = getSessionColumns(db) + const rows = db.query( + `SELECT id, + ${nullableColumn(columns, 'source')}, + ${nullableColumn(columns, 'model')}, + ${nullableColumn(columns, 'billing_provider')}, + ${numberColumn(columns, 'input_tokens')}, + ${numberColumn(columns, 'output_tokens')}, + ${numberColumn(columns, 'cache_read_tokens')}, + ${numberColumn(columns, 'cache_write_tokens')}, + ${numberColumn(columns, 'reasoning_tokens')}, + ${nullableColumn(columns, 'estimated_cost_usd')}, + ${nullableColumn(columns, 'actual_cost_usd')}, + ${numberColumn(columns, 'api_call_count')}, + ${numberColumn(columns, 'tool_call_count')}, + ${nullableColumn(columns, 'started_at')}, + ${nullableColumn(columns, 'ended_at')}, + ${nullableColumn(columns, 'title')} + FROM sessions + WHERE id = ?`, + [decoded.sessionId], + ) + const row = rows[0] + if (!row) return + + const messageColumns = getMessageColumns(db) + const orderColumns = ['timestamp', 'id'].filter(name => messageColumns.has(name)) + const orderBy = orderColumns.length > 0 ? `ORDER BY ${orderColumns.join(' ASC, ')} ASC` : '' + const messages = db.query( + `SELECT ${numberColumn(messageColumns, 'id')}, + role, + content, + tool_calls, + ${nullableColumn(messageColumns, 'tool_name')}, + ${nullableColumn(messageColumns, 'timestamp')} + FROM messages + WHERE session_id = ? + ${orderBy}`, + [decoded.sessionId], + ) + + const inputTokens = row.input_tokens ?? 0 + const outputTokens = row.output_tokens ?? 0 + const cacheReadTokens = row.cache_read_tokens ?? 0 + const cacheWriteTokens = row.cache_write_tokens ?? 0 + const reasoningTokens = row.reasoning_tokens ?? 0 + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens === 0) return + + const model = row.model ?? 'unknown' + const { tools, toolSequence, bashCommands } = collectTools(messages) + const projectInfo = inferProject(messages, sanitizeProject(profile)) + const timestamp = parseTimestamp(row.started_at) + const dedupKey = `hermes:${profile}:${row.id}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const calculatedCost = calculateCost( + model, + inputTokens, + outputTokens + reasoningTokens, + cacheWriteTokens, + cacheReadTokens, + 0, + ) + const actualCost = row.actual_cost_usd ?? row.estimated_cost_usd ?? 0 + const costUSD = actualCost > 0 ? actualCost : calculatedCost + + result = { + provider: 'hermes', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheWriteTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: cacheReadTokens, + reasoningTokens, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + turnId: `${row.id}:session`, + toolSequence: toolSequence.length > 0 ? toolSequence : undefined, + userMessage: firstUserMessage(messages), + sessionId: row.id, + project: projectInfo.project, + projectPath: projectInfo.projectPath, + } + } catch (err) { + process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) + return + } finally { + db.close() + } + + if (result) yield result + }, + } +} + +export function createHermesProvider(hermesHomeOverride?: string): Provider { + const hermesHome = getHermesHome(hermesHomeOverride) + return { + name: 'hermes', + displayName: 'Hermes Agent', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbs = await findStateDbs(hermesHome) + const sessions: SessionSource[] = [] + for (const { dbPath, profile } of dbs) { + sessions.push(...await discoverFromDb(dbPath, profile)) + } + return sessions + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys, hermesHome) + }, + } +} + +export const hermes = createHermesProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index d5a4dd85..d56bc8e2 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,7 @@ import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' import { gemini } from './gemini.js' +import { hermes } from './hermes.js' import { ibmBob } from './ibm-bob.js' import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' @@ -120,7 +121,7 @@ async function loadCrush(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [ag, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()]) diff --git a/src/session-cache.ts b/src/session-cache.ts index 7251625c..86fe0121 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -71,7 +71,7 @@ export type SessionCache = { // ── Constants ────────────────────────────────────────────────────────── -export const CACHE_VERSION = 3 +export const CACHE_VERSION = 4 const CACHE_FILE = 'session-cache.json' const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 @@ -79,6 +79,7 @@ const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 const PROVIDER_ENV_VARS: Record = { claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'], codex: ['CODEX_HOME'], + hermes: ['HERMES_HOME'], droid: ['FACTORY_DIR'], cursor: ['XDG_DATA_HOME'], 'cursor-agent': ['XDG_DATA_HOME'], @@ -94,6 +95,7 @@ const PROVIDER_ENV_VARS: Record = { const PROVIDER_PARSE_VERSIONS: Record = { claude: 'worktree-project-grouping-v1', cline: 'worktree-project-grouping-v1', + hermes: 'reasoning-output-accounting-v1', 'ibm-bob': 'worktree-project-grouping-v1', 'kilo-code': 'worktree-project-grouping-v1', 'roo-code': 'worktree-project-grouping-v1', diff --git a/src/types.ts b/src/types.ts index f35551ba..a85306d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,7 @@ export type SessionSummary = { totalCostUSD: number totalInputTokens: number totalOutputTokens: number + totalReasoningTokens: number totalCacheReadTokens: number totalCacheWriteTokens: number apiCalls: number diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index f0bea5a3..8e59be53 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index. describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts new file mode 100644 index 00000000..d3b8aa69 --- /dev/null +++ b/tests/providers/hermes.test.ts @@ -0,0 +1,480 @@ +import { mkdir, mkdtemp, rm } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { calculateCost } from '../../src/models.js' +import { createHermesProvider } from '../../src/providers/hermes.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' +import type { DateRange } from '../../src/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string +let cacheDir: string +let originalHermesHome: string | undefined +let originalCodeburnCacheDir: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'hermes-provider-test-')) + cacheDir = await mkdtemp(join(tmpdir(), 'hermes-provider-cache-')) + originalHermesHome = process.env['HERMES_HOME'] + originalCodeburnCacheDir = process.env['CODEBURN_CACHE_DIR'] +}) + +afterEach(async () => { + if (originalHermesHome === undefined) delete process.env['HERMES_HOME'] + else process.env['HERMES_HOME'] = originalHermesHome + if (originalCodeburnCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = originalCodeburnCacheDir + await rm(tmpDir, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) +}) + +function createHermesDb(homeDir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const dbPath = join(homeDir, 'state.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + model TEXT, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + api_call_count INTEGER DEFAULT 0, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + started_at REAL, + ended_at REAL, + title TEXT + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL + ) + `) + db.close() + return dbPath +} + +function createLegacyHermesDb(homeDir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const dbPath = join(homeDir, 'state.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + model TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + started_at REAL + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_calls TEXT, + timestamp REAL NOT NULL + ) + `) + db.close() + return dbPath +} + +async function createProfileHermesDb(hermesHome: string, profile: string): Promise { + const profileDir = join(hermesHome, 'profiles', profile) + await mkdir(profileDir, { recursive: true }) + return createHermesDb(profileDir) +} + +function insertSession(db: TestDb, values: { + id: string + source?: string + model?: string + billingProvider?: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + reasoningTokens: number + estimatedCost?: number | null + actualCost?: number | null + apiCalls?: number + toolCalls?: number + startedAt: number + title?: string +}): void { + db.prepare( + `INSERT INTO sessions ( + id, source, model, billing_provider, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, estimated_cost_usd, + actual_cost_usd, api_call_count, tool_call_count, started_at, title + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + values.id, + values.source ?? 'cli', + values.model ?? 'gpt-5.5', + values.billingProvider ?? 'openai-codex', + values.inputTokens, + values.outputTokens, + values.cacheReadTokens, + values.cacheWriteTokens, + values.reasoningTokens, + values.estimatedCost ?? null, + values.actualCost ?? null, + values.apiCalls ?? 1, + values.toolCalls ?? 0, + values.startedAt, + values.title ?? values.id, + ) +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function dayRange(): DateRange { + return { + start: new Date('2026-05-23T00:00:00.000Z'), + end: new Date('2026-05-23T23:59:59.999Z'), + } +} + +async function loadParserWithHermesHome(hermesHome: string, codeburnCacheDir: string) { + process.env['HERMES_HOME'] = hermesHome + process.env['CODEBURN_CACHE_DIR'] = codeburnCacheDir + vi.resetModules() + const parser = await import('../../src/parser.js') + return parser +} + +async function collectCalls(hermesHome: string, sourcePath: string): Promise { + const provider = createHermesProvider(hermesHome) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ path: sourcePath, project: 'hermes', provider: 'hermes' }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('hermes provider', () => { + it('discovers state.db sessions with token usage', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'session-1', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 50, + cacheWriteTokens: 0, + reasoningTokens: 5, + startedAt: 1779549200, + title: 'Test Project', + }) + db.prepare( + `INSERT INTO sessions (id, source, model, input_tokens, output_tokens, started_at, title) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run('empty', 'cli', 'gpt-5.5', 0, 0, 1779549300, 'Empty') + }) + + const provider = createHermesProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('hermes') + expect(sessions[0]!.path).toBe(`${dbPath}#hermes-session=session-1`) + expect(sessions[0]!.project).toBe('default') + }) + + it('parses session-level token usage and tool calls from messages', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'session-1', + source: 'tui', + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 40, + reasoningTokens: 25, + estimatedCost: 0.12, + apiCalls: 3, + toolCalls: 2, + startedAt: 1779549200, + title: 'Provider Work', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('session-1', 'user', 'Add Hermes support', 1779549201) + db.prepare('INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)') + .run( + 'session-1', + 'assistant', + '', + JSON.stringify([ + { function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/file.ts' }) } }, + { function: { name: 'terminal', arguments: JSON.stringify({ command: 'npm test' }) } }, + ]), + 1779549202, + ) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=session-1`) + expect(calls).toHaveLength(1) + expect(calls[0]!).toMatchObject({ + provider: 'hermes', + model: 'gpt-5.5', + inputTokens: 1000, + outputTokens: 200, + cacheReadInputTokens: 300, + cacheCreationInputTokens: 40, + cachedInputTokens: 300, + reasoningTokens: 25, + costUSD: 0.12, + userMessage: 'Add Hermes support', + sessionId: 'session-1', + deduplicationKey: 'hermes:default:session-1', + }) + expect(calls[0]!.tools).toEqual(['Read', 'Bash']) + expect(calls[0]!.bashCommands).toEqual(['npm test']) + expect(calls[0]!.toolSequence).toEqual([ + [{ tool: 'Read', file: '/tmp/file.ts' }, { tool: 'Bash', command: 'npm test' }], + ]) + }) + + + it('maps composio MCP tools before generic MCP prefixes', () => { + const provider = createHermesProvider(tmpDir) + expect(provider.toolDisplayName('mcp_composio_GMAIL_SEND_EMAIL')).toBe('MCP') + expect(provider.toolDisplayName('mcp__github__create_issue')).toBe('mcp__github__create_issue') + }) + + it('falls back to calculateCost when no actual or estimated cost is recorded', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'no-cost-session', + model: 'claude-sonnet-4-20250514', + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 50, + estimatedCost: null, + actualCost: null, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('no-cost-session', 'user', 'Test calculateCost fallback', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost-session`) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-20250514', 1000, 250, 0, 0, 0)) + expect(calls[0]!.reasoningTokens).toBe(50) + }) + + it('does not split multibyte characters when truncating the first user message', async () => { + const dbPath = createHermesDb(tmpDir) + const message = `${'a'.repeat(499)}😀truncated tail` + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'emoji-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + estimatedCost: 0.01, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('emoji-session', 'user', message, 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=emoji-session`) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe(`${'a'.repeat(499)}😀`) + }) + + it('parses legacy databases that predate optional accounting columns', async () => { + const dbPath = createLegacyHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + db.prepare( + `INSERT INTO sessions (id, model, input_tokens, output_tokens, started_at) + VALUES (?, ?, ?, ?, ?)`, + ).run('legacy-session', 'gpt-5.5', 12, 34, 1779549200) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('legacy-session', 'user', 'Legacy Hermes DB', 1779549201) + }) + + const provider = createHermesProvider(tmpDir) + const discovered = await provider.discoverSessions() + expect(discovered.map(s => s.path)).toEqual([`${dbPath}#hermes-session=legacy-session`]) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=legacy-session`) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 12, + outputTokens: 34, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + reasoningTokens: 0, + userMessage: 'Legacy Hermes DB', + }) + }) + + it('discovers root and profile databases and preserves Hermes DB accounting through parser aggregation', async () => { + const rootDbPath = createHermesDb(tmpDir) + const profileDbPath = await createProfileHermesDb(tmpDir, 'coder') + + withTestDb(rootDbPath, (db) => { + insertSession(db, { + id: 'root-session', + model: 'gpt-5.5', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + reasoningTokens: 5, + estimatedCost: 0.25, + actualCost: 0.30, + startedAt: 1779494400, + title: 'Root session', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('root-session', 'user', 'Current working directory: /tmp/root-project\nImplement root support', 1779494401) + }) + withTestDb(profileDbPath, (db) => { + insertSession(db, { + id: 'profile-session', + model: 'gpt-5.5', + inputTokens: 200, + outputTokens: 70, + cacheReadTokens: 11, + cacheWriteTokens: 13, + reasoningTokens: 17, + estimatedCost: 0.42, + actualCost: null, + startedAt: 1779501600, + title: 'Profile session', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('profile-session', 'user', 'Current working directory: /tmp/profile-project\nImplement profile support', 1779501601) + }) + + const provider = createHermesProvider(tmpDir) + const discovered = await provider.discoverSessions() + expect(discovered.map(s => s.path).sort()).toEqual([ + `${profileDbPath}#hermes-session=profile-session`, + `${rootDbPath}#hermes-session=root-session`, + ].sort()) + expect(discovered.map(s => s.project).sort()).toEqual(['coder', 'default']) + + const rootCalls = await collectCalls(tmpDir, `${rootDbPath}#hermes-session=root-session`) + const profileCalls = await collectCalls(tmpDir, `${profileDbPath}#hermes-session=profile-session`) + expect(rootCalls[0]).toMatchObject({ inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, reasoningTokens: 5, costUSD: 0.30 }) + expect(profileCalls[0]).toMatchObject({ inputTokens: 200, outputTokens: 70, cacheReadInputTokens: 11, cacheCreationInputTokens: 13, reasoningTokens: 17, costUSD: 0.42 }) + + const { clearSessionCache, parseAllSessions } = await loadParserWithHermesHome(tmpDir, cacheDir) + clearSessionCache() + const projects = await parseAllSessions(dayRange(), 'hermes') + const sessions = projects.flatMap(project => project.sessions) + expect(sessions).toHaveLength(2) + expect(sessions.reduce((sum, session) => sum + session.totalInputTokens, 0)).toBe(300) + expect(sessions.reduce((sum, session) => sum + session.totalOutputTokens, 0)).toBe(90) + expect(sessions.reduce((sum, session) => sum + session.totalReasoningTokens, 0)).toBe(22) + expect(sessions.reduce((sum, session) => sum + session.totalCacheReadTokens, 0)).toBe(41) + expect(sessions.reduce((sum, session) => sum + session.totalCacheWriteTokens, 0)).toBe(53) + expect(sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)).toBeCloseTo(0.72) + expect(projects.map(project => project.project).sort()).toEqual(['tmp-profile-project', 'tmp-root-project']) + + const modelTokens = sessions.flatMap(session => Object.values(session.modelBreakdown).map(model => model.tokens)) + expect(modelTokens.reduce((sum, tokens) => sum + tokens.outputTokens, 0)).toBe(90) + expect(modelTokens.reduce((sum, tokens) => sum + tokens.reasoningTokens, 0)).toBe(22) + }) + + it('treats sibling profile-like directories as default sessions', async () => { + const profileLikeDir = join(dirname(tmpDir), `${basename(tmpDir)}-profiles_backup`, 'coder') + await mkdir(profileLikeDir, { recursive: true }) + const dbPath = createHermesDb(profileLikeDir) + + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'sibling-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('sibling-session', 'user', 'Sibling profile-like directory', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=sibling-session`) + expect(calls[0]).toMatchObject({ + deduplicationKey: 'hermes:default:sibling-session', + project: 'default', + }) + }) + + it('infers projects from Windows current working directory messages', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'windows-cwd-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('windows-cwd-session', 'user', 'Current working directory: C:\\AI_LAB\\OPENCLAW\nAdd Windows path support', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=windows-cwd-session`) + expect(calls[0]).toMatchObject({ + project: 'C--AI_LAB-OPENCLAW', + projectPath: 'C:\\AI_LAB\\OPENCLAW', + }) + }) +})