Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions apps/hermes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<div align="center">
<img src="https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg" alt="ccusage logo" width="256" height="256">
<h1>@ccusage/hermes</h1>
</div>

> Analyze [Hermes Agent](https://hermes-agent.nousresearch.com/) usage from the local SQLite database with the same reporting experience as `ccusage`.

## Quick Start

```bash
# Recommended - always include @latest
npx @ccusage/hermes@latest --help
bunx @ccusage/hermes@latest --help

# Alternative package runners
pnpm dlx @ccusage/hermes
pnpx @ccusage/hermes
```

### Recommended: Shell Alias

```bash
# bash/zsh: alias ccusage-hermes='bunx @ccusage/hermes@latest'
# fish: alias ccusage-hermes 'bunx @ccusage/hermes@latest'

# Then simply run:
ccusage-hermes daily
ccusage-hermes monthly --json
```

> 💡 The CLI looks for Hermes usage data under `HERMES_DATA_DIR` (defaults to `~/.hermes`).

## Common Commands

```bash
# Daily usage grouped by date (default command)
npx @ccusage/hermes@latest daily

# Weekly usage grouped by ISO week
npx @ccusage/hermes@latest weekly

# Monthly usage grouped by month
npx @ccusage/hermes@latest monthly

# Session-level detailed report
npx @ccusage/hermes@latest session

# JSON output for scripting
npx @ccusage/hermes@latest daily --json

# Compact mode for screenshots/sharing
npx @ccusage/hermes@latest daily --compact
```

Useful environment variables:

- `HERMES_DATA_DIR` – override the Hermes data directory (defaults to `~/.hermes`)
- `LOG_LEVEL` – control consola log verbosity (0 silent … 5 trace)

## Features

- 📊 **Daily Reports**: View token usage and costs aggregated by date
- 📅 **Weekly Reports**: View usage grouped by ISO week (YYYY-Www)
- 🗓️ **Monthly Reports**: View usage aggregated by month (YYYY-MM)
- 💬 **Session Reports**: View usage grouped by conversation sessions
- 📈 **Responsive Tables**: Automatic layout adjustment for terminal width
- 🤖 **Model Tracking**: See which models you're using across providers
- 💵 **Accurate Cost Calculation**: Uses LiteLLM pricing database to calculate costs from token data
- 🔄 **Cache Token Support**: Tracks and displays cache creation and cache read tokens separately
- 📄 **JSON Output**: Export data in structured JSON format with `--json`
- 📱 **Compact Mode**: Use `--compact` flag for narrow terminals, perfect for screenshots

## Cost Calculation

Hermes stores `estimated_cost_usd` and `actual_cost_usd` in the session table. When these are present and greater than zero, they are used directly. Otherwise, costs are calculated from token usage data using the LiteLLM pricing database.

## Data Location

Hermes stores usage data in:

- **Database**: `~/.hermes/state.db` (SQLite)
- **Table**: `sessions`
- **Key columns**: `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `model`, `started_at`

## License

MIT © [@ryoppippi](https://github.com/ryoppippi)
16 changes: 16 additions & 0 deletions apps/hermes/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ryoppippi } from '@ryoppippi/eslint-config';

/** @type {import('eslint').Linter.FlatConfig[]} */
const config = ryoppippi(
{
type: 'app',
stylistic: false,
},
{
rules: {
'test/no-importing-vitest-globals': 'error',
},
},
);

export default config;
66 changes: 66 additions & 0 deletions apps/hermes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@ccusage/hermes",
"type": "module",
"version": "18.0.11",
"description": "Usage analysis tool for Hermes Agent sessions",
"contributors": [
"ryoppippi"
],
"license": "MIT",
"funding": "https://github.com/ryoppippi/ccusage?sponsor=1",
"homepage": "https://github.com/ryoppippi/ccusage#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ryoppippi/ccusage.git",
"directory": "apps/hermes"
},
"bugs": {
"url": "https://github.com/ryoppippi/ccusage/issues"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"bin": {
"ccusage-hermes": "./src/index.ts"
},
"files": [
"dist"
],
"publishConfig": {
"bin": {
"ccusage-hermes": "./dist/index.js"
}
},
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"build": "tsdown",
"format": "pnpm run lint --fix",
"lint": "eslint --cache .",
"prepack": "pnpm run build && clean-pkg-json",
"prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build",
"start": "bun ./src/index.ts",
"test": "TZ=UTC vitest",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@ccusage/internal": "workspace:*",
"@ccusage/terminal": "workspace:*",
"@praha/byethrow": "catalog:runtime",
"@ryoppippi/eslint-config": "catalog:lint",
"@typescript/native-preview": "catalog:types",
"clean-pkg-json": "catalog:release",
"es-toolkit": "catalog:runtime",
"eslint": "catalog:lint",
"fast-sort": "catalog:runtime",
"fs-fixture": "catalog:testing",
"gunshi": "catalog:runtime",
"path-type": "catalog:runtime",
"picocolors": "catalog:runtime",
"tsdown": "catalog:build",
"unplugin-macros": "catalog:build",
"unplugin-unused": "catalog:build",
"valibot": "catalog:runtime",
"vitest": "catalog:testing"
}
}
72 changes: 72 additions & 0 deletions apps/hermes/src/aggregate-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';
import { calculateCostForEntry } from './cost-utils.ts';
import type { LoadedUsageEntry } from './data-loader.ts';

export const TABLE_COLUMN_COUNT = 8;

export type AggregatedRow = {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
totalCost: number;
modelsUsed: string[];
};

/**
* Aggregate token usage and cost for a group of entries.
*/
export async function aggregateGroup(
entries: LoadedUsageEntry[],
fetcher: LiteLLMPricingFetcher,
): Promise<AggregatedRow> {
let inputTokens = 0;
let outputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
let totalCost = 0;
const modelsSet = new Set<string>();

for (const entry of entries) {
inputTokens += entry.usage.inputTokens;
outputTokens += entry.usage.outputTokens;
cacheCreationTokens += entry.usage.cacheCreationInputTokens;
cacheReadTokens += entry.usage.cacheReadInputTokens;
totalCost += await calculateCostForEntry(entry, fetcher);
modelsSet.add(entry.model);
}

return {
inputTokens,
outputTokens,
cacheCreationTokens,
cacheReadTokens,
totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens,
totalCost,
modelsUsed: Array.from(modelsSet),
Comment on lines +31 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Surface pricing lookup failures instead of folding them into totals.

apps/hermes/src/cost-utils.ts returns 0 when LiteLLM pricing resolution fails, so this helper turns “couldn't price this entry” into “free usage.” That makes every downstream report undercount spend without any warning.

Please propagate a failure or keep a separate “pricing unavailable” signal so the CLI can tell users the totals are incomplete.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/hermes/src/aggregate-utils.ts` around lines 31 - 47, The aggregation
currently folds failed pricing lookups into totals by treating a zero cost as
success; change aggregateTotals logic in the loop over entries so it detects
pricing failures from calculateCostForEntry (either by catching exceptions from
calculateCostForEntry(entry, fetcher) or by treating a sentinel return value
like null/undefined as “pricing unavailable”) and instead of adding 0 to
totalCost set a boolean flag (e.g., pricingUnavailable) or rethrow the error so
callers know totals are incomplete; update the returned object to include this
pricingUnavailable flag (or propagate the error) and do not silently include
failed entries in totalCost so downstream CLI/reporting can surface incomplete
pricing.

};
}

export type Totals = {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
totalCost: number;
};

/**
* Compute totals from an array of aggregated rows.
*/
export function computeTotals(rows: Array<Pick<AggregatedRow, 'inputTokens' | 'outputTokens' | 'cacheCreationTokens' | 'cacheReadTokens' | 'totalTokens' | 'totalCost'>>): Totals {
return {
inputTokens: rows.reduce((sum, d) => sum + d.inputTokens, 0),
outputTokens: rows.reduce((sum, d) => sum + d.outputTokens, 0),
cacheCreationTokens: rows.reduce((sum, d) => sum + d.cacheCreationTokens, 0),
cacheReadTokens: rows.reduce((sum, d) => sum + d.cacheReadTokens, 0),
totalTokens: rows.reduce((sum, d) => sum + d.totalTokens, 0),
totalCost: rows.reduce((sum, d) => sum + d.totalCost, 0),
};
}
149 changes: 149 additions & 0 deletions apps/hermes/src/commands/daily.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';
import {
addEmptySeparatorRow,
formatCurrency,
formatDateCompact,
formatModelsDisplayMultiline,
formatNumber,
ResponsiveTable,
} from '@ccusage/terminal/table';
import { groupBy } from 'es-toolkit';
import { define } from 'gunshi';
import pc from 'picocolors';
import { aggregateGroup, computeTotals, TABLE_COLUMN_COUNT } from '../aggregate-utils.ts';
import { loadHermesSessions } from '../data-loader.ts';
import { logger } from '../logger.ts';

function formatDateUTC(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

export const dailyCommand = define({
name: 'daily',
description: 'Show Hermes token usage grouped by day',
args: {
json: {
type: 'boolean',
short: 'j',
description: 'Output in JSON format',
},
compact: {
type: 'boolean',
description: 'Force compact table mode',
},
},
async run(ctx) {
const jsonOutput = Boolean(ctx.values.json);

const entries = loadHermesSessions();

if (entries.length === 0) {
const output = jsonOutput
? JSON.stringify({ daily: [], totals: null })
: 'No Hermes usage data found.';
// eslint-disable-next-line no-console
console.log(output);
return;
}

using fetcher = new LiteLLMPricingFetcher({ offline: false, logger });

const entriesByDate = groupBy(entries, (entry) => formatDateUTC(entry.timestamp));

const dailyData: Array<{
date: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
totalCost: number;
modelsUsed: string[];
}> = [];

for (const [date, dayEntries] of Object.entries(entriesByDate)) {
const agg = await aggregateGroup(dayEntries, fetcher);
dailyData.push({ date, ...agg });
}

dailyData.sort((a, b) => a.date.localeCompare(b.date));

const totals = computeTotals(dailyData);

if (jsonOutput) {
// eslint-disable-next-line no-console
console.log(
JSON.stringify(
{
daily: dailyData,
totals,
},
null,
2,
),
);
return;
}

// eslint-disable-next-line no-console
console.log('\n📊 Hermes Token Usage Report - Daily\n');

const table: ResponsiveTable = new ResponsiveTable({
head: [
'Date',
'Models',
'Input',
'Output',
'Cache Create',
'Cache Read',
'Total Tokens',
'Cost (USD)',
],
colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],
compactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'],
compactColAligns: ['left', 'left', 'right', 'right', 'right'],
compactThreshold: 100,
forceCompact: Boolean(ctx.values.compact),
style: { head: ['cyan'] },
dateFormatter: (dateStr: string) => formatDateCompact(dateStr),
});

for (const data of dailyData) {
table.push([
data.date,
formatModelsDisplayMultiline(data.modelsUsed),
formatNumber(data.inputTokens),
formatNumber(data.outputTokens),
formatNumber(data.cacheCreationTokens),
formatNumber(data.cacheReadTokens),
formatNumber(data.totalTokens),
formatCurrency(data.totalCost),
]);
}

addEmptySeparatorRow(table, TABLE_COLUMN_COUNT);
table.push([
pc.yellow('Total'),
'',
pc.yellow(formatNumber(totals.inputTokens)),
pc.yellow(formatNumber(totals.outputTokens)),
pc.yellow(formatNumber(totals.cacheCreationTokens)),
pc.yellow(formatNumber(totals.cacheReadTokens)),
pc.yellow(formatNumber(totals.totalTokens)),
pc.yellow(formatCurrency(totals.totalCost)),
]);

// eslint-disable-next-line no-console
console.log(table.toString());

if (table.isCompactMode()) {
// eslint-disable-next-line no-console
console.log('\nRunning in Compact Mode');
// eslint-disable-next-line no-console
console.log('Expand terminal width to see cache metrics and total tokens');
}
},
});
4 changes: 4 additions & 0 deletions apps/hermes/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { dailyCommand } from './daily.ts';
export { monthlyCommand } from './monthly.ts';
export { sessionCommand } from './session.ts';
export { weeklyCommand } from './weekly.ts';
Loading