From 645b3c2bdd6b9cbd6094a3af4922b11da184e886 Mon Sep 17 00:00:00 2001 From: Anthony Armijo <77902463+anthonyarmijo@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:49:25 -0700 Subject: [PATCH 1/2] feat: add Hermes Agent provider Reads session-level usage from Hermes SQLite state databases (~/.hermes/state.db and per-profile state.dbs). Tracks input, output, cache read/write, reasoning tokens, stored costs, tools, shell commands, and inferred projects. - Provider reads both root and profile state databases - Cost fallback: actual_cost_usd > estimated_cost_usd > LiteLLM pricing - inferProject filters to user/system messages only - Discovery query capped at LIMIT 10000 - sanitizeProject handles repeated leading separators - All five review items from PR #386 addressed - Tested against real Hermes data (557MB state.db with 198 sessions) Closes #368. Supersedes #386. --- CHANGELOG.md | 7 + docs/providers/README.md | 1 + docs/providers/hermes.md | 67 +++++ src/parser.ts | 6 +- src/providers/hermes.ts | 464 ++++++++++++++++++++++++++++++ src/providers/index.ts | 3 +- src/session-cache.ts | 2 + src/types.ts | 1 + tests/provider-registry.test.ts | 2 +- tests/providers/hermes.test.ts | 480 ++++++++++++++++++++++++++++++++ 10 files changed, 1030 insertions(+), 3 deletions(-) create mode 100644 docs/providers/hermes.md create mode 100644 src/providers/hermes.ts create mode 100644 tests/providers/hermes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e69274..3b1cb822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ - CNY currency support. (#430) - Contribution heatmap insight. (#437) +### 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. Supersedes + #386, closes #368. + ### Fixed (CLI) - **Per-file parse isolation.** A single malformed session file no longer aborts the run or empties the daily-history trend; parse failures are cached diff --git a/docs/providers/README.md b/docs/providers/README.md index 1a090546..9eb60671 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -17,6 +17,7 @@ For the architectural picture, see `../architecture.md`. | [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.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/parser.ts b/src/parser.ts index eca39f8d..aa40e6dc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1287,6 +1287,7 @@ function buildSessionSummary( let totalSavings = 0 let totalInput = 0 let totalOutput = 0 + let totalReasoning = 0 let totalCacheRead = 0 let totalCacheWrite = 0 let apiCalls = 0 @@ -1329,6 +1330,7 @@ function buildSessionSummary( totalSavings += callSavings totalInput += call.usage.inputTokens totalOutput += call.usage.outputTokens + totalReasoning += call.usage.reasoningTokens totalCacheRead += call.usage.cacheReadInputTokens totalCacheWrite += call.usage.cacheCreationInputTokens apiCalls++ @@ -1349,6 +1351,7 @@ function buildSessionSummary( modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens + modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens for (const tool of extractCoreTools(call.tools)) { toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 } @@ -1384,6 +1387,7 @@ function buildSessionSummary( totalSavingsUSD: totalSavings, totalInputTokens: totalInput, totalOutputTokens: totalOutput, + totalReasoningTokens: totalReasoning, totalCacheReadTokens: totalCacheRead, totalCacheWriteTokens: totalCacheWrite, apiCalls, @@ -1737,7 +1741,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || 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..cb93ebd0 --- /dev/null +++ b/src/providers/hermes.ts @@ -0,0 +1,464 @@ +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 == null) 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) { + if (msg.role !== 'user' && msg.role !== 'system') continue + 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} + LIMIT 10000`, + ) + + 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) + + // Hermes bills reasoning tokens at the output rate (same as Gemini). + // The LiteLLM model table is used as a fallback when Hermes has not + // stored an actual or estimated cost for the session. + const calculatedCost = calculateCost( + model, + inputTokens, + outputTokens + reasoningTokens, + cacheWriteTokens, + cacheReadTokens, + 0, + ) + const costUSD = + (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! + : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! + : 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 3fb5580c..dca275af 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -6,6 +6,7 @@ import { copilot } from './copilot.js' import { droid } from './droid.js' import { devin } from './devin.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' @@ -169,7 +170,7 @@ async function loadZcode(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/src/session-cache.ts b/src/session-cache.ts index 46b243c0..9fc833ab 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -90,6 +90,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'], @@ -110,6 +111,7 @@ const PROVIDER_PARSE_VERSIONS: Record = { claude: 'cowork-space-grouping-v1', cline: 'worktree-project-grouping-v1', copilot: 'otel-durable-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 67c1e190..a33f2483 100644 --- a/src/types.ts +++ b/src/types.ts @@ -136,6 +136,7 @@ export type SessionSummary = { totalSavingsUSD: 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 3f53ff08..6c9b6c03 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', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok']) }) 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', + }) + }) +}) From e80da3139a15745205a0410195686fa104fce1f7 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sun, 21 Jun 2026 23:50:10 +0200 Subject: [PATCH 2/2] fix(hermes): use sessions.cwd, flag estimated cost, propagate SQLITE_BUSY - Read the populated sessions.cwd column for project grouping; fall back to scraping the transcript, then the profile name. The current Hermes build no longer writes "Current working directory:" lines into messages. - Set costIsEstimated when the figure is the LiteLLM fallback (no recorded cost). - Re-throw SQLITE_BUSY from the discovery and read paths so a transient lock on the live state.db is retried instead of being cached as an empty result. - Tests: add the cwd column to fixtures; cover cwd grouping, the estimated flag, and tool-result tool_name extraction. --- src/providers/hermes.ts | 23 +++++++++-- tests/providers/hermes.test.ts | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index cb93ebd0..309b29e9 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -11,6 +11,7 @@ type HermesSessionRow = { id: string source: string | null model: string | null + cwd: string | null billing_provider: string | null input_tokens: number | null output_tokens: number | null @@ -294,6 +295,7 @@ async function discoverFromDb(dbPath: string, profile: string): Promise, hermesHome: `SELECT id, ${nullableColumn(columns, 'source')}, ${nullableColumn(columns, 'model')}, + ${nullableColumn(columns, 'cwd')}, ${nullableColumn(columns, 'billing_provider')}, ${numberColumn(columns, 'input_tokens')}, ${numberColumn(columns, 'output_tokens')}, @@ -374,7 +377,13 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: const model = row.model ?? 'unknown' const { tools, toolSequence, bashCommands } = collectTools(messages) - const projectInfo = inferProject(messages, sanitizeProject(profile)) + // Hermes records the session's working directory in sessions.cwd. + // Prefer it; fall back to scraping a "Current working directory:" line + // from the transcript (older builds), then to the profile name. + const cwd = row.cwd?.trim() + const projectInfo = cwd + ? { project: sanitizeProject(cwd), projectPath: cwd } + : inferProject(messages, sanitizeProject(profile)) const timestamp = parseTimestamp(row.started_at) const dedupKey = `hermes:${profile}:${row.id}` if (seenKeys.has(dedupKey)) return @@ -391,10 +400,14 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: cacheReadTokens, 0, ) - const costUSD = + const recordedCost = (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! - : calculatedCost + : null + // When Hermes stored no cost (e.g. subscription-billed sessions), the + // figure is our LiteLLM-priced estimate from the session token totals. + const costUSD = recordedCost ?? calculatedCost + const costIsEstimated = recordedCost === null result = { provider: 'hermes', @@ -407,6 +420,7 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: reasoningTokens, webSearchRequests: 0, costUSD, + costIsEstimated, tools, bashCommands, timestamp, @@ -420,6 +434,9 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: projectPath: projectInfo.projectPath, } } catch (err) { + // A transient lock on the live state.db must propagate so the caller + // retries, not get swallowed into an empty (negatively cached) result. + if (isSqliteBusyError(err)) throw err process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) return } finally { diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts index d3b8aa69..e475bf03 100644 --- a/tests/providers/hermes.test.ts +++ b/tests/providers/hermes.test.ts @@ -48,6 +48,7 @@ function createHermesDb(homeDir: string): string { id TEXT PRIMARY KEY, source TEXT, model TEXT, + cwd TEXT, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, @@ -120,6 +121,7 @@ function insertSession(db: TestDb, values: { id: string source?: string model?: string + cwd?: string | null billingProvider?: string inputTokens: number outputTokens: number @@ -135,14 +137,15 @@ function insertSession(db: TestDb, values: { }): void { db.prepare( `INSERT INTO sessions ( - id, source, model, billing_provider, input_tokens, output_tokens, + id, source, model, cwd, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( values.id, values.source ?? 'cli', values.model ?? 'gpt-5.5', + values.cwd ?? null, values.billingProvider ?? 'openai-codex', values.inputTokens, values.outputTokens, @@ -477,4 +480,67 @@ skipUnlessSqlite('hermes provider', () => { projectPath: 'C:\\AI_LAB\\OPENCLAW', }) }) + + it('groups by the sessions.cwd column when present, ahead of message scraping', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'cwd-session', + cwd: '/Users/me/projects/codeburn', + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('cwd-session', 'user', 'Current working directory: /tmp/decoy\nbuild it', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=cwd-session`) + expect(calls[0]).toMatchObject({ + project: 'Users-me-projects-codeburn', + projectPath: '/Users/me/projects/codeburn', + }) + }) + + it('flags estimated cost only when Hermes recorded none', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'no-cost', + inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + startedAt: 1779549200, + }) + insertSession(db, { + id: 'recorded-cost', + actualCost: 1.23, + inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + startedAt: 1779549300, + }) + }) + + const noCost = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost`) + expect(noCost[0]!.costIsEstimated).toBe(true) + + const recorded = await collectCalls(tmpDir, `${dbPath}#hermes-session=recorded-cost`) + expect(recorded[0]).toMatchObject({ costUSD: 1.23, costIsEstimated: false }) + }) + + it('counts tool-result messages by their tool_name', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'tool-result-session', + inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)') + .run('tool-result-session', 'tool', null, 'read_file', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=tool-result-session`) + expect(calls[0]!.tools).toContain('Read') + }) })