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 { 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 { 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') }) })