Merge pull request #544 from getagentseal/feat/hermes-provider-rebased

feat(providers): add Hermes Agent provider
This commit is contained in:
Resham Joshi 2026-06-22 00:06:30 +02:00 committed by GitHub
commit d68e8ec90f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1113 additions and 3 deletions

View file

@ -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

View file

@ -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` |

67
docs/providers/hermes.md Normal file
View file

@ -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/<profile>/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 `<dbPath>#hermes-session=<sessionId>` 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'))"`.

View file

@ -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,

481
src/providers/hermes.ts Normal file
View file

@ -0,0 +1,481 @@
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
cwd: 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<string, string> = {
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<ProfileDb[]> {
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<string> {
return new Set(db.query<TableInfoRow>('PRAGMA table_info(sessions)').map(row => row.name))
}
function numberColumn(columns: Set<string>, name: TableColumn): string {
return columns.has(name) ? `coalesce(${name}, 0) AS ${name}` : `0 AS ${name}`
}
function nullableColumn(columns: Set<string>, name: TableColumn): string {
return columns.has(name) ? name : `NULL AS ${name}`
}
function getMessageColumns(db: SqliteDatabase): Set<string> {
return new Set(db.query<TableInfoRow>('PRAGMA table_info(messages)').map(row => row.name))
}
function usageExpression(columns: Set<string>): string {
const usageColumns: Array<keyof HermesSessionRow> = [
'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<string, unknown>
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<SessionSource[]> {
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<HermesSessionRow>(
`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) {
if (isSqliteBusyError(err)) throw 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<string>, hermesHome: string): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<HermesSessionRow>(
`SELECT id,
${nullableColumn(columns, 'source')},
${nullableColumn(columns, 'model')},
${nullableColumn(columns, 'cwd')},
${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<HermesMessageRow>(
`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)
// 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
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 recordedCost =
(row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd!
: (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd!
: 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',
model,
inputTokens,
outputTokens,
cacheCreationInputTokens: cacheWriteTokens,
cacheReadInputTokens: cacheReadTokens,
cachedInputTokens: cacheReadTokens,
reasoningTokens,
webSearchRequests: 0,
costUSD,
costIsEstimated,
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) {
// 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 {
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<SessionSource[]> {
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<string>): SessionParser {
return createParser(source, seenKeys, hermesHome)
},
}
}
export const hermes = createHermesProvider()

View file

@ -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<Provider | null> {
}
}
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.

View file

@ -90,6 +90,7 @@ const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
const PROVIDER_ENV_VARS: Record<string, string[]> = {
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<string, string> = {
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',

View file

@ -136,6 +136,7 @@ export type SessionSummary = {
totalSavingsUSD: number
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
totalCacheReadTokens: number
totalCacheWriteTokens: number
apiCalls: number

View file

@ -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', () => {

View file

@ -0,0 +1,546 @@
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,
cwd 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<string> {
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
cwd?: string | null
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, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
values.id,
values.source ?? 'cli',
values.model ?? 'gpt-5.5',
values.cwd ?? null,
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<ParsedProviderCall[]> {
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',
})
})
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')
})
})