-
-
Notifications
You must be signed in to change notification settings - Fork 639
feat(apps/hermes): add Hermes Agent usage analyzer #973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| }; | ||
| } | ||
|
|
||
| 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), | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| } | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Surface pricing lookup failures instead of folding them into totals.
apps/hermes/src/cost-utils.tsreturns0when 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