Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<name>/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.
Expand Down
1 change: 1 addition & 0 deletions docs/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
67 changes: 67 additions & 0 deletions docs/providers/hermes.md
Original file line number Diff line number Diff line change
@@ -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'))"`.
23 changes: 19 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -235,15 +239,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
sessions: p.sessions.length,
}))

const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; reasoningTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
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
}
Expand Down Expand Up @@ -335,6 +340,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
tokens: {
input: totalInput,
output: totalOutput,
reasoning: totalReasoning,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
},
Expand All @@ -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')
Expand Down Expand Up @@ -399,6 +409,11 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
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
Expand Down
6 changes: 5 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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++
Expand All @@ -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

Expand Down Expand Up @@ -1293,6 +1296,7 @@ function buildSessionSummary(
totalCostUSD: totalCost,
totalInputTokens: totalInput,
totalOutputTokens: totalOutput,
totalReasoningTokens: totalReasoning,
totalCacheReadTokens: totalCacheRead,
totalCacheWriteTokens: totalCacheWrite,
apiCalls,
Expand Down Expand Up @@ -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,
Expand Down
Loading