From 0ea10d249e686010441f57e82522178083a7bcfd Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Thu, 21 May 2026 12:47:21 +0300 Subject: [PATCH] feat: add Hermes Agent provider --- package.json | 1 + src/parser.ts | 2 +- src/providers/hermes.ts | 720 ++++++++++++++++++++++++++++++ src/providers/index.ts | 21 +- src/providers/types.ts | 1 + src/session-cache.ts | 2 +- tests/cli-hermes-provider.test.ts | 209 +++++++++ tests/provider-registry.test.ts | 9 + tests/providers/hermes.test.ts | 458 +++++++++++++++++++ 9 files changed, 1420 insertions(+), 3 deletions(-) create mode 100644 src/providers/hermes.ts create mode 100644 tests/cli-hermes-provider.test.ts create mode 100644 tests/providers/hermes.test.ts diff --git a/package.json b/package.json index 9e9c585b..c93454f4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "kimi", "ibm-bob", "opencode", + "hermes", "pi", "codebuff", "ai-coding", diff --git a/src/parser.ts b/src/parser.ts index c5a91e10..0e2e9b54 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1531,7 +1531,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: call.provider === 'mistral-vibe' ? call.costUSD : undefined, + costUSD: call.provider === 'mistral-vibe' || call.preserveCostUSD ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts new file mode 100644 index 00000000..50e0c083 --- /dev/null +++ b/src/providers/hermes.ts @@ -0,0 +1,720 @@ +import { stat } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { extractBashCommands } from '../bash-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { blobToText, getSqliteLoadError, isSqliteAvailable, isSqliteBusyError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +const HERMES_SESSION_SEPARATOR = '#hermes-session=' +const STRUCTURED_CONTENT_PREFIX = '\0json:' + +type SessionRow = { + id: string + source: string + model: Uint8Array | string | null + parent_session_id: string | null + started_at: number + ended_at: number | null + title: Uint8Array | 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 +} + +type MessageRow = { + id: number + session_id: string + role: string + content: Uint8Array | string | null + tool_calls: Uint8Array | string | null + tool_name: Uint8Array | string | null + timestamp: number + token_count: number | null + finish_reason: Uint8Array | string | null + reasoning: Uint8Array | string | null + reasoning_content: Uint8Array | string | null +} + +type ToolCall = { + name: string + args: Record +} + +type AssistantTurn = { + msg: MessageRow + model: string + userMessage: string + tools: string[] + bashCommands: string[] + webSearchRequests: number + hasExplicitToolCalls: boolean + outputWeight: number + reasoningWeight: number + turnId: string +} + +const toolNameMap: Record = { + terminal: 'Bash', + bash: 'Bash', + shell: 'Bash', + process: 'Bash', + execute_code: 'Bash', + read_file: 'Read', + file_read: 'Read', + read: 'Read', + write_file: 'Write', + write: 'Write', + edit: 'Edit', + patch: 'Edit', + search_replace: 'Edit', + str_replace: 'Edit', + search_files: 'Grep', + grep: 'Grep', + glob: 'Glob', + find: 'Grep', + browser_navigate: 'WebFetch', + browser_click: 'WebFetch', + browser_type: 'WebFetch', + browser_snapshot: 'WebFetch', + browser_scroll: 'WebFetch', + browser_press: 'WebFetch', + browser_back: 'WebFetch', + browser_console: 'WebFetch', + browser_get_images: 'WebFetch', + browser_vision: 'Vision', + vision_analyze: 'Vision', + web_search: 'WebSearch', + web_extract: 'WebFetch', + web_fetch: 'WebFetch', + delegate_task: 'Agent', + todo: 'TodoWrite', + skill_view: 'Skill', + skill_manage: 'Skill', + skills_list: 'Skill', + session_search: 'Skill', + clarify: 'Conversation', + memory: 'Memory', +} + +function expandHome(path: string): string { + if (path === '~') return homedir() + if (path.startsWith('~/')) return join(homedir(), path.slice(2)) + return path +} + +function getHermesHome(override?: string): string { + if (override) return expandHome(override) + const configured = process.env['HERMES_HOME'] + return configured ? expandHome(configured) : join(homedir(), '.hermes') +} + +function getStateDbPath(hermesHome: string): string { + return join(hermesHome, 'state.db') +} + +function sourcePathFor(dbPath: string, sessionId: string): string { + return `${dbPath}${HERMES_SESSION_SEPARATOR}${encodeURIComponent(sessionId)}` +} + +function parseSourcePath(sourcePath: string): { dbPath: string; sessionId: string } { + const idx = sourcePath.lastIndexOf(HERMES_SESSION_SEPARATOR) + if (idx === -1) return { dbPath: sourcePath, sessionId: '' } + const encoded = sourcePath.slice(idx + HERMES_SESSION_SEPARATOR.length) + let sessionId = encoded + try { + sessionId = decodeURIComponent(encoded) + } catch { + // keep the raw suffix + } + return { dbPath: sourcePath.slice(0, idx), sessionId } +} + +function text(value: Uint8Array | string | null | undefined): string { + return blobToText(value).trim() +} + +function safeNumber(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +function parseTimestamp(raw: number | null | undefined): string { + if (typeof raw !== 'number' || !Number.isFinite(raw)) return '' + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function sanitizeProject(name: string): string { + return name + .replace(/^\//, '') + .replace(/[/\\:]/g, '-') + .replace(/[\x00-\x1F\x7F-\x9F]/g, '') + .slice(0, 100) +} + +function projectName(row: SessionRow): string { + const title = text(row.title) + if (title) return sanitizeProject(title) + if (row.source === 'cli') return 'hermes-cli' + if (row.source) return sanitizeProject(`hermes-${row.source}`) + return sanitizeProject(row.id) +} + +type SchemaCheckResult = + | { ok: true } + | { ok: false; missing: string[] } + +function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult { + const missing: string[] = [] + const checks = [ + { + name: 'sessions', + sql: `SELECT + id, source, CAST(model AS BLOB) AS model, parent_session_id, + started_at, ended_at, CAST(title AS BLOB) AS title, + input_tokens, output_tokens, cache_read_tokens, + cache_write_tokens, reasoning_tokens, estimated_cost_usd, + actual_cost_usd + FROM sessions LIMIT 0`, + }, + { + name: 'messages', + sql: `SELECT + id, session_id, role, CAST(content AS BLOB) AS content, + CAST(tool_calls AS BLOB) AS tool_calls, + CAST(tool_name AS BLOB) AS tool_name, + timestamp, token_count, CAST(finish_reason AS BLOB) AS finish_reason, + CAST(reasoning AS BLOB) AS reasoning, + CAST(reasoning_content AS BLOB) AS reasoning_content + FROM messages LIMIT 0`, + }, + ] + + for (const check of checks) { + try { + db.query(check.sql) + } catch (err) { + if (isSqliteBusyError(err)) throw err + missing.push(check.name) + } + } + return missing.length === 0 ? { ok: true } : { ok: false, missing } +} + +const warnedHermesSchemas = new Set() + +function warnUnrecognizedHermesSchemaOnce(missing: string[]): void { + const key = missing.slice().sort().join(',') + if (warnedHermesSchemas.has(key)) return + warnedHermesSchemas.add(key) + process.stderr.write( + `codeburn: Hermes state.db is missing expected tables or columns (${missing.join(', ')}). ` + + `Run Hermes once to apply migrations, or report at https://github.com/getagentseal/codeburn/issues if this persists on a current Hermes install.\n`, + ) +} + +async function isFile(path: string): Promise { + const s = await stat(path).catch(() => null) + return Boolean(s?.isFile()) +} + +function hasSessionActivity(row: SessionRow): boolean { + return safeNumber(row.input_tokens) + + safeNumber(row.output_tokens) + + safeNumber(row.cache_read_tokens) + + safeNumber(row.cache_write_tokens) + + safeNumber(row.reasoning_tokens) + + safeNumber(row.estimated_cost_usd) + + safeNumber(row.actual_cost_usd) > 0 +} + +function decodeContent(raw: Uint8Array | string | null): string { + const value = blobToText(raw) + if (!value) return '' + + if (value.startsWith(STRUCTURED_CONTENT_PREFIX)) { + try { + return normalizeContent(JSON.parse(value.slice(STRUCTURED_CONTENT_PREFIX.length))) + } catch { + return '' + } + } + + return value +} + +function normalizeContent(content: unknown): string { + if (typeof content === 'string') return content + if (typeof content === 'number' || typeof content === 'boolean') return String(content) + if (!content) return '' + if (Array.isArray(content)) { + return content + .map(part => { + if (typeof part === 'string') return part + if (!part || typeof part !== 'object') return '' + const record = part as Record + if (typeof record['text'] === 'string') return record['text'] + if (typeof record['content'] === 'string') return record['content'] + return '' + }) + .filter(Boolean) + .join(' ') + } + if (typeof content === 'object') { + const record = content as Record + if (typeof record['text'] === 'string') return record['text'] + if (typeof record['content'] === 'string') return record['content'] + } + return '' +} + +function parseArgs(raw: unknown): Record { + if (!raw) return {} + if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record + if (typeof raw !== 'string') return {} + try { + const parsed = JSON.parse(raw) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + } catch { + return {} + } +} + +function parseToolCalls(raw: Uint8Array | string | null): ToolCall[] { + const payload = blobToText(raw) + if (!payload) return [] + + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return [] + } + + const list = Array.isArray(parsed) ? parsed : [parsed] + const calls: ToolCall[] = [] + + for (const item of list) { + if (!item || typeof item !== 'object') continue + const record = item as Record + const fn = record['function'] + const fnRecord = fn && typeof fn === 'object' ? fn as Record : null + const name = ( + typeof fnRecord?.['name'] === 'string' ? fnRecord['name'] : + typeof record['name'] === 'string' ? record['name'] : + typeof record['tool_name'] === 'string' ? record['tool_name'] : + '' + ).trim() + if (!name) continue + const args = parseArgs(fnRecord?.['arguments'] ?? record['arguments'] ?? record['input'] ?? record['args']) + calls.push({ name, args }) + } + + return calls +} + +function normalizeToolName(rawTool: string): string { + const clean = rawTool.trim() + if (!clean) return '' + if (clean.startsWith('mcp__')) return clean + + const mapped = toolNameMap[clean] + if (mapped) return mapped + + if (clean.startsWith('mcp_')) { + const rest = clean.slice(4) + const separator = rest.indexOf('_') + if (separator > 0 && separator < rest.length - 1) { + return `mcp__${rest.slice(0, separator)}__${rest.slice(separator + 1)}` + } + } + + return clean +} + +function appendTool(target: { tools: string[]; bashCommands: string[]; webSearchRequests: number }, rawName: string, args: Record = {}): void { + const tool = normalizeToolName(rawName) + if (!tool) return + + target.tools.push(tool) + if (tool === 'WebSearch') target.webSearchRequests += 1 + + if (tool !== 'Bash') return + const command = args['command'] ?? args['cmd'] ?? args['input'] + if (typeof command === 'string' && command.trim()) { + target.bashCommands.push(...extractBashCommands(command)) + } +} + +function estimateWeight(...parts: string[]): number { + const length = parts.reduce((sum, part) => sum + part.length, 0) + return Math.max(1, Math.ceil(length / 4)) +} + +function allocateIntegers(total: number, weights: number[]): number[] { + const count = weights.length + if (count === 0) return [] + + const budget = Math.max(0, Math.round(total)) + if (budget === 0) return weights.map(() => 0) + + const normalized = weights.map(w => Number.isFinite(w) && w > 0 ? w : 1) + const totalWeight = normalized.reduce((sum, weight) => sum + weight, 0) + const raw = normalized.map(weight => (budget * weight) / totalWeight) + const allocated = raw.map(Math.floor) + let remainder = budget - allocated.reduce((sum, value) => sum + value, 0) + + const order = raw + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .sort((a, b) => b.fraction - a.fraction || a.index - b.index) + + for (const { index } of order) { + if (remainder <= 0) break + allocated[index] += 1 + remainder-- + } + + return allocated +} + +function allocateCost(total: number, weights: number[]): number[] { + const count = weights.length + if (count === 0) return [] + const safeTotal = safeNumber(total) + if (safeTotal === 0) return weights.map(() => 0) + const normalized = weights.map(w => Number.isFinite(w) && w > 0 ? w : 1) + const totalWeight = normalized.reduce((sum, weight) => sum + weight, 0) + return normalized.map(weight => safeTotal * weight / totalWeight) +} + +function buildTurns(session: SessionRow, messages: MessageRow[]): AssistantTurn[] { + const turns: AssistantTurn[] = [] + const model = text(session.model) || 'unknown' + let currentUserMessage = text(session.title).slice(0, 500) + let pendingAssistant: AssistantTurn | null = null + let turnOrdinal = 0 + + for (const msg of messages) { + if (msg.role === 'user') { + const userText = decodeContent(msg.content).replace(/\s+/g, ' ').trim() + if (userText) currentUserMessage = userText.slice(0, 500) + pendingAssistant = null + continue + } + + if (msg.role === 'assistant') { + const body = decodeContent(msg.content) + const reasoning = text(msg.reasoning_content) || text(msg.reasoning) + const toolCalls = parseToolCalls(msg.tool_calls) + const tokenCount = safeNumber(msg.token_count) + const turn: AssistantTurn = { + msg, + model, + userMessage: currentUserMessage, + tools: [], + bashCommands: [], + webSearchRequests: 0, + hasExplicitToolCalls: toolCalls.length > 0, + outputWeight: tokenCount || estimateWeight(body, blobToText(msg.tool_calls)), + reasoningWeight: estimateWeight(reasoning), + turnId: `${msg.session_id}:turn-${turnOrdinal++}`, + } + + for (const call of toolCalls) { + appendTool(turn, call.name, call.args) + } + + turns.push(turn) + pendingAssistant = turn + continue + } + + if (msg.role === 'tool' && pendingAssistant && !pendingAssistant.hasExplicitToolCalls) { + const toolName = text(msg.tool_name) + if (toolName) appendTool(pendingAssistant, toolName) + } + } + + return turns +} + +function usageCost(row: SessionRow): { value: number; estimated: boolean } { + const actual = safeNumber(row.actual_cost_usd) + if (actual > 0) return { value: actual, estimated: false } + const estimated = safeNumber(row.estimated_cost_usd) + if (estimated > 0) return { value: estimated, estimated: true } + return { value: 0, estimated: true } +} + +function createSyntheticTurn(row: SessionRow): AssistantTurn { + const timestamp = row.ended_at ?? row.started_at + return { + msg: { + id: 0, + session_id: row.id, + role: 'assistant', + content: null, + tool_calls: null, + tool_name: null, + timestamp, + token_count: null, + finish_reason: null, + reasoning: null, + reasoning_content: null, + }, + model: text(row.model) || 'unknown', + userMessage: text(row.title).slice(0, 500), + tools: [], + bashCommands: [], + webSearchRequests: 0, + hasExplicitToolCalls: false, + outputWeight: 1, + reasoningWeight: 1, + turnId: `${row.id}:synthetic`, + } +} + +async function discoverFromDb(dbPath: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + const schema = validateSchemaDetailed(db) + if (!schema.ok) { + warnUnrecognizedHermesSchemaOnce(schema.missing) + return [] + } + + const rows = db.query( + `SELECT + s.id, s.source, CAST(s.model AS BLOB) AS model, s.parent_session_id, + s.started_at, s.ended_at, CAST(s.title AS BLOB) AS title, + s.input_tokens, s.output_tokens, s.cache_read_tokens, + s.cache_write_tokens, s.reasoning_tokens, s.estimated_cost_usd, + s.actual_cost_usd + FROM sessions s + WHERE (s.parent_session_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM sessions parent WHERE parent.id = s.parent_session_id + )) + AND ( + COALESCE(s.input_tokens, 0) > 0 OR + COALESCE(s.output_tokens, 0) > 0 OR + COALESCE(s.cache_read_tokens, 0) > 0 OR + COALESCE(s.cache_write_tokens, 0) > 0 OR + COALESCE(s.reasoning_tokens, 0) > 0 OR + COALESCE(s.estimated_cost_usd, 0) > 0 OR + COALESCE(s.actual_cost_usd, 0) > 0 OR + EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id) OR + EXISTS (SELECT 1 FROM sessions child WHERE child.parent_session_id = s.id) + ) + ORDER BY s.started_at DESC, s.id DESC`, + ) + + return rows.map(row => ({ + path: sourcePathFor(dbPath, row.id), + project: projectName(row), + provider: 'hermes', + })) + } catch { + return [] + } finally { + db.close() + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const { dbPath, sessionId: rootSessionId } = parseSourcePath(source.path) + if (!rootSessionId) return + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Hermes state.db: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + const schema = validateSchemaDetailed(db) + if (!schema.ok) { + warnUnrecognizedHermesSchemaOnce(schema.missing) + return + } + + const sessions = db.query( + `WITH RECURSIVE session_tree(id, depth, path) AS ( + SELECT id, 0, char(31) || id || char(31) FROM sessions WHERE id = ? + UNION ALL + SELECT child.id, parent.depth + 1, parent.path || child.id || char(31) + FROM sessions child + JOIN session_tree parent ON child.parent_session_id = parent.id + WHERE parent.depth < 128 + AND instr(parent.path, char(31) || child.id || char(31)) = 0 + ) + SELECT + id, source, CAST(model AS BLOB) AS model, parent_session_id, + started_at, ended_at, CAST(title AS BLOB) AS title, + input_tokens, output_tokens, cache_read_tokens, + cache_write_tokens, reasoning_tokens, estimated_cost_usd, + actual_cost_usd + FROM sessions + WHERE id IN (SELECT id FROM session_tree) + ORDER BY (SELECT MIN(depth) FROM session_tree WHERE session_tree.id = sessions.id) ASC, started_at ASC, id ASC`, + [rootSessionId], + ) + + const messages = db.query( + `WITH RECURSIVE session_tree(id) AS ( + SELECT id FROM sessions WHERE id = ? + UNION + SELECT child.id + FROM sessions child + JOIN session_tree parent ON child.parent_session_id = parent.id + ) + SELECT + id, session_id, role, CAST(content AS BLOB) AS content, + CAST(tool_calls AS BLOB) AS tool_calls, + CAST(tool_name AS BLOB) AS tool_name, + timestamp, token_count, CAST(finish_reason AS BLOB) AS finish_reason, + CAST(reasoning AS BLOB) AS reasoning, + CAST(reasoning_content AS BLOB) AS reasoning_content + FROM messages + WHERE session_id IN (SELECT id FROM session_tree) + ORDER BY timestamp ASC, id ASC`, + [rootSessionId], + ) + + const messagesBySession = new Map() + for (const msg of messages) { + const list = messagesBySession.get(msg.session_id) ?? [] + list.push(msg) + messagesBySession.set(msg.session_id, list) + } + + for (const session of sessions) { + let turns = buildTurns(session, messagesBySession.get(session.id) ?? []) + if (turns.length === 0 && hasSessionActivity(session)) { + turns = [createSyntheticTurn(session)] + } + if (turns.length === 0) continue + + const equalWeights = turns.map(() => 1) + const outputWeights = turns.map(turn => turn.outputWeight) + const reasoningWeights = turns.map(turn => turn.reasoningWeight) + const inputTokens = allocateIntegers(safeNumber(session.input_tokens), equalWeights) + const outputTokens = allocateIntegers(safeNumber(session.output_tokens), outputWeights) + const cacheReadTokens = allocateIntegers(safeNumber(session.cache_read_tokens), equalWeights) + const cacheWriteTokens = allocateIntegers(safeNumber(session.cache_write_tokens), equalWeights) + const reasoningTokens = allocateIntegers(safeNumber(session.reasoning_tokens), reasoningWeights) + const storedCost = usageCost(session) + const costWeights = turns.map((_, i) => + inputTokens[i]! + outputTokens[i]! + cacheReadTokens[i]! + cacheWriteTokens[i]! + reasoningTokens[i]!, + ) + const storedCosts = allocateCost(storedCost.value, costWeights) + + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]! + const deduplicationKey = `hermes:${turn.msg.session_id}:${turn.msg.id}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + const callInputTokens = inputTokens[i]! + const callOutputTokens = outputTokens[i]! + const callCacheReadTokens = cacheReadTokens[i]! + const callCacheWriteTokens = cacheWriteTokens[i]! + const callReasoningTokens = reasoningTokens[i]! + const calculatedCost = calculateCost( + turn.model, + callInputTokens, + callOutputTokens + callReasoningTokens, + callCacheWriteTokens, + callCacheReadTokens, + turn.webSearchRequests, + ) + const costUSD = storedCost.value > 0 ? storedCosts[i]! : calculatedCost + + const hasUsage = callInputTokens + callOutputTokens + callCacheReadTokens + callCacheWriteTokens + callReasoningTokens > 0 + if (!hasUsage && costUSD === 0 && turn.tools.length === 0) continue + + yield { + provider: 'hermes', + model: turn.model, + inputTokens: callInputTokens, + outputTokens: callOutputTokens, + cacheCreationInputTokens: callCacheWriteTokens, + cacheReadInputTokens: callCacheReadTokens, + cachedInputTokens: callCacheReadTokens, + reasoningTokens: callReasoningTokens, + webSearchRequests: turn.webSearchRequests, + costUSD, + preserveCostUSD: storedCost.value > 0, + costIsEstimated: storedCost.value > 0 ? storedCost.estimated : calculatedCost > 0, + tools: turn.tools, + bashCommands: turn.bashCommands, + timestamp: parseTimestamp(turn.msg.timestamp), + speed: 'standard', + deduplicationKey, + turnId: turn.turnId, + userMessage: turn.userMessage, + sessionId: rootSessionId, + } + } + } + } catch (err) { + process.stderr.write(`codeburn: cannot parse Hermes state.db: ${err instanceof Error ? err.message : err}\n`) + return + } finally { + db.close() + } + }, + } +} + +export function createHermesProvider(hermesHome?: string): Provider { + const home = getHermesHome(hermesHome) + + return { + name: 'hermes', + displayName: 'Hermes', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return normalizeToolName(rawTool) + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + + const dbPath = getStateDbPath(home) + if (!await isFile(dbPath)) return [] + return discoverFromDb(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const hermes = createHermesProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index d5a4dd85..239f794c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -20,6 +20,8 @@ let antigravityProvider: Provider | null = null let antigravityLoadAttempted = false let warpProvider: Provider | null = null let warpLoadAttempted = false +let hermesProvider: Provider | null = null +let hermesLoadAttempted = false async function loadAntigravity(): Promise { if (antigravityLoadAttempted) return antigravityProvider @@ -45,6 +47,18 @@ async function loadWarp(): Promise { } } +async function loadHermes(): Promise { + if (hermesLoadAttempted) return hermesProvider + hermesLoadAttempted = true + try { + const { hermes } = await import('./hermes.js') + hermesProvider = hermes + return hermes + } catch { + return null + } +} + let gooseProvider: Provider | null = null let gooseLoadAttempted = false @@ -123,7 +137,7 @@ async function loadCrush(): Promise { const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { - const [ag, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()]) + const [ag, gs, cursor, opencode, cursorAgent, crush, warp, hermes] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadHermes()]) const all = [...coreProviders] if (ag) all.push(ag) if (gs) all.push(gs) @@ -132,6 +146,7 @@ export async function getAllProviders(): Promise { if (cursorAgent) all.push(cursorAgent) if (crush) all.push(crush) if (warp) all.push(warp) + if (hermes) all.push(hermes) return all } @@ -179,5 +194,9 @@ export async function getProvider(name: string): Promise { const w = await loadWarp() return w ?? undefined } + if (name === 'hermes') { + const h = await loadHermes() + return h ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/src/providers/types.ts b/src/providers/types.ts index 8e0c9372..d2409001 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -20,6 +20,7 @@ export type ParsedProviderCall = { webSearchRequests: number costUSD: number costIsEstimated?: boolean + preserveCostUSD?: boolean tools: string[] bashCommands: string[] timestamp: string diff --git a/src/session-cache.ts b/src/session-cache.ts index 107b4e62..39de13d5 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -82,6 +82,7 @@ const PROVIDER_ENV_VARS: Record = { goose: ['XDG_DATA_HOME'], crush: ['XDG_DATA_HOME'], warp: ['WARP_DB_PATH'], + hermes: ['HERMES_HOME'], antigravity: ['CODEBURN_CACHE_DIR'], qwen: ['QWEN_DATA_DIR'], 'ibm-bob': ['XDG_CONFIG_HOME'], @@ -346,4 +347,3 @@ export async function cleanupOrphanedTempFiles(): Promise { } catch {} } - diff --git a/tests/cli-hermes-provider.test.ts b/tests/cli-hermes-provider.test.ts new file mode 100644 index 00000000..26cbb57e --- /dev/null +++ b/tests/cli-hermes-provider.test.ts @@ -0,0 +1,209 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' + +import { describe, expect, it } from 'vitest' +import { isSqliteAvailable } from '../src/sqlite.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + HERMES_HOME: join(home, '.hermes'), + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + TZ: 'UTC', + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +function createHermesDb(home: string): string { + const hermesHome = join(home, '.hermes') + mkdirSync(hermesHome, { recursive: true }) + const dbPath = join(hermesHome, 'state.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + 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, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + handoff_state TEXT, + handoff_platform TEXT, + handoff_error TEXT + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + 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, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT, + platform_message_id TEXT + ) + `) + db.close() + return dbPath +} + +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() + } +} + +const describeIfSqlite = isSqliteAvailable() ? describe : describe.skip + +describeIfSqlite('CLI Hermes provider regression', () => { + it('reads Hermes CLI usage from ~/.hermes/state.db with --provider hermes', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-hermes-cli-')) + + try { + const dbPath = createHermesDb(home) + withTestDb(dbPath, db => { + db.prepare(` + INSERT INTO sessions ( + id, source, model, started_at, ended_at, message_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, estimated_cost_usd, title + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + '20260521_070000_abc123', + 'cli', + 'anthropic/claude-sonnet-4.6', + 1779345600, + 1779345900, + 2, + 1200, + 340, + 90, + 40, + 25, + 0.031, + 'Hermes auth fix', + ) + db.prepare(` + INSERT INTO messages ( + id, session_id, role, content, tool_calls, timestamp, reasoning_content + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + 1, + '20260521_070000_abc123', + 'user', + 'wire Hermes support into codeburn', + null, + 1779345600, + null, + ) + db.prepare(` + INSERT INTO messages ( + id, session_id, role, content, tool_calls, timestamp, reasoning_content + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + 2, + '20260521_070000_abc123', + 'assistant', + 'implemented the provider', + JSON.stringify([ + { name: 'terminal', arguments: JSON.stringify({ command: 'npm test -- --run tests/providers/hermes.test.ts' }) }, + { name: 'write_file', arguments: JSON.stringify({ path: 'src/providers/hermes.ts' }) }, + ]), + 1779345660, + 'checked the state.db schema', + ) + }) + + const result = runCli([ + '--format', 'json', + '--from', '2026-05-21', + '--to', '2026-05-21', + '--provider', 'hermes', + ], home) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + + const report = JSON.parse(result.stdout) as { + overview: { + calls: number + cost: number + tokens: { input: number; output: number; cacheRead: number; cacheWrite: number } + } + models: Array<{ name: string; calls: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cost: number }> + tools: Array<{ name: string; calls: number }> + } + const model = report.models.find(m => m.name === 'Sonnet 4.6') + + expect(report.overview.calls).toBe(1) + expect(report.overview.tokens.input).toBe(1200) + expect(report.overview.tokens.output).toBe(340) + expect(report.overview.tokens.cacheRead).toBe(90) + expect(report.overview.tokens.cacheWrite).toBe(40) + expect(report.overview.cost).toBeCloseTo(0.031, 8) + expect(model).toBeDefined() + expect(model!.calls).toBe(1) + expect(model!.inputTokens).toBe(1200) + expect(model!.outputTokens).toBe(340) + expect(model!.cacheReadTokens).toBe(90) + expect(model!.cacheWriteTokens).toBe(40) + expect(model!.cost).toBeCloseTo(0.031, 8) + expect(report.tools.find(t => t.name === 'Bash')?.calls).toBe(1) + expect(report.tools.find(t => t.name === 'Write')?.calls).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index f0bea5a3..19ee9e07 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -30,9 +30,18 @@ describe('provider registry', () => { expect(names).toContain('claude') expect(names).toContain('codex') expect(names).toContain('warp') + expect(names).toContain('hermes') expect(names.length).toBeGreaterThanOrEqual(2) }) + it('hermes model and tool display names are normalized', async () => { + const hermes = await getProvider('hermes') + expect(hermes).toBeDefined() + expect(hermes!.modelDisplayName('anthropic/claude-sonnet-4.6')).toBe('Sonnet 4.6') + expect(hermes!.toolDisplayName('terminal')).toBe('Bash') + expect(hermes!.toolDisplayName('mcp_github_search_code')).toBe('mcp__github__search_code') + }) + it('warp model and tool display names are normalized', async () => { const warp = await getProvider('warp') expect(warp).toBeDefined() diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts new file mode 100644 index 00000000..4527fde9 --- /dev/null +++ b/tests/providers/hermes.test.ts @@ -0,0 +1,458 @@ +import { mkdtemp, rm } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHermesProvider } from '../../src/providers/hermes.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +type SessionFixture = { + id: string + source?: string + model?: string | null + title?: string | null + parentSessionId?: string | null + startedAt?: number + endedAt?: number | null + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number + estimatedCostUsd?: number | null + actualCostUsd?: number | null +} + +type MessageFixture = { + id: number + sessionId: string + role: string + content?: string | null + toolCalls?: unknown + toolName?: string | null + timestamp: number + tokenCount?: number | null + reasoningContent?: string | null +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'hermes-provider-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function createHermesDb(home: string): string { + const hermesHome = join(home, '.hermes') + mkdirSync(hermesHome, { recursive: true }) + const dbPath = join(hermesHome, 'state.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + 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, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + handoff_state TEXT, + handoff_platform TEXT, + handoff_error TEXT + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + 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, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT, + platform_message_id TEXT + ) + `) + db.close() + return dbPath +} + +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 insertSession(db: TestDb, fixture: SessionFixture): void { + db.prepare(` + INSERT INTO sessions ( + id, source, model, parent_session_id, started_at, ended_at, input_tokens, + output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, + estimated_cost_usd, actual_cost_usd, title, message_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + `).run( + fixture.id, + fixture.source ?? 'cli', + fixture.model ?? 'claude-sonnet-4-6', + fixture.parentSessionId ?? null, + fixture.startedAt ?? 1779345600, + fixture.endedAt ?? null, + fixture.inputTokens ?? 0, + fixture.outputTokens ?? 0, + fixture.cacheReadTokens ?? 0, + fixture.cacheWriteTokens ?? 0, + fixture.reasoningTokens ?? 0, + fixture.estimatedCostUsd ?? null, + fixture.actualCostUsd ?? null, + fixture.title ?? null, + ) +} + +function insertMessage(db: TestDb, fixture: MessageFixture): void { + db.prepare(` + INSERT INTO messages ( + id, session_id, role, content, tool_calls, tool_name, timestamp, + token_count, reasoning_content + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + fixture.id, + fixture.sessionId, + fixture.role, + fixture.content ?? null, + fixture.toolCalls ? JSON.stringify(fixture.toolCalls) : null, + fixture.toolName ?? null, + fixture.timestamp, + fixture.tokenCount ?? null, + fixture.reasoningContent ?? null, + ) +} + +async function collect(provider: ReturnType, dbPath: string, sessionId: string, seenKeys = new Set()): Promise { + const source = { + path: `${dbPath}#hermes-session=${encodeURIComponent(sessionId)}`, + project: 'hermes', + provider: 'hermes', + } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('hermes provider', () => { + it('discovers root sessions from Hermes state.db', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { + id: '20260521_070000_abc123', + title: 'Fix auth flow', + inputTokens: 100, + }) + insertSession(db, { + id: 'child-session', + title: 'child', + parentSessionId: '20260521_070000_abc123', + inputTokens: 100, + }) + }) + + const sessions = await createHermesProvider(join(tmpDir, '.hermes')).discoverSessions() + + expect(sessions).toEqual([{ + path: `${dbPath}#hermes-session=20260521_070000_abc123`, + project: 'Fix auth flow', + provider: 'hermes', + }]) + }) + + it('warns and skips discovery when Hermes state.db has drifted columns', async () => { + const hermesHome = join(tmpDir, '.hermes') + mkdirSync(hermesHome, { recursive: true }) + const dbPath = join(hermesHome, 'state.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions (id TEXT PRIMARY KEY); + CREATE TABLE messages (id INTEGER PRIMARY KEY); + `) + db.close() + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + try { + const sessions = await createHermesProvider(hermesHome).discoverSessions() + + expect(sessions).toEqual([]) + expect(write.mock.calls.some(([message]) => + String(message).includes('Hermes state.db is missing expected tables or columns'), + )).toBe(true) + } finally { + write.mockRestore() + } + }) + + it('parses session totals, tool calls, bash commands, and stored cost', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { + id: 'sess-1', + model: 'anthropic/claude-sonnet-4.6', + title: 'Auth bug', + inputTokens: 1000, + outputTokens: 250, + cacheReadTokens: 80, + cacheWriteTokens: 20, + reasoningTokens: 30, + actualCostUsd: 0.42, + }) + insertMessage(db, { + id: 1, + sessionId: 'sess-1', + role: 'user', + content: 'fix auth bug', + timestamp: 1779345600, + }) + insertMessage(db, { + id: 2, + sessionId: 'sess-1', + role: 'assistant', + content: 'patched the auth flow', + reasoningContent: 'checked route guard', + toolCalls: [ + { name: 'terminal', arguments: JSON.stringify({ command: 'npm test && git status' }) }, + { function: { name: 'read_file', arguments: JSON.stringify({ path: 'src/auth.ts' }) } }, + ], + timestamp: 1779345660, + }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('hermes') + expect(call.model).toBe('anthropic/claude-sonnet-4.6') + expect(call.inputTokens).toBe(1000) + expect(call.outputTokens).toBe(250) + expect(call.cacheReadInputTokens).toBe(80) + expect(call.cacheCreationInputTokens).toBe(20) + expect(call.reasoningTokens).toBe(30) + expect(call.costUSD).toBeCloseTo(0.42, 8) + expect(call.costIsEstimated).toBe(false) + expect(call.preserveCostUSD).toBe(true) + expect(call.tools).toEqual(['Bash', 'Read']) + expect(call.bashCommands).toEqual(['npm', 'git']) + expect(call.userMessage).toBe('fix auth bug') + expect(call.timestamp).toBe('2026-05-21T06:41:00.000Z') + expect(call.sessionId).toBe('sess-1') + expect(call.turnId).toBe('sess-1:turn-0') + expect(call.deduplicationKey).toBe('hermes:sess-1:2') + }) + + it('attaches tool result rows to the preceding assistant turn', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-tools', title: 'Search', inputTokens: 10 }) + insertMessage(db, { + id: 1, + sessionId: 'sess-tools', + role: 'assistant', + content: '', + timestamp: 1779345600, + }) + insertMessage(db, { + id: 2, + sessionId: 'sess-tools', + role: 'tool', + toolName: 'web_search', + content: 'results', + timestamp: 1779345601, + }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'sess-tools') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['WebSearch']) + expect(calls[0]!.webSearchRequests).toBe(1) + }) + + it('does not double count tool result rows when assistant tool_calls are present', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-no-double-tools', title: 'Search', inputTokens: 10 }) + insertMessage(db, { + id: 1, + sessionId: 'sess-no-double-tools', + role: 'assistant', + content: '', + toolCalls: [{ name: 'web_search', arguments: JSON.stringify({ query: 'hermes cli' }) }], + timestamp: 1779345600, + }) + insertMessage(db, { + id: 2, + sessionId: 'sess-no-double-tools', + role: 'tool', + toolName: 'web_search', + content: 'results', + timestamp: 1779345601, + }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'sess-no-double-tools') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['WebSearch']) + expect(calls[0]!.webSearchRequests).toBe(1) + }) + + it('keeps recursive session parsing bounded when parent links form a cycle', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { id: 'cycle-a', parentSessionId: 'cycle-b', startedAt: 1779345600, inputTokens: 100 }) + insertSession(db, { id: 'cycle-b', parentSessionId: 'cycle-a', startedAt: 1779345601, inputTokens: 200 }) + insertMessage(db, { id: 1, sessionId: 'cycle-a', role: 'assistant', content: 'a', timestamp: 1779345600 }) + insertMessage(db, { id: 2, sessionId: 'cycle-b', role: 'assistant', content: 'b', timestamp: 1779345601 }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'cycle-a') + + expect(calls.map(call => call.deduplicationKey)).toEqual([ + 'hermes:cycle-a:1', + 'hermes:cycle-b:2', + ]) + }) + + it('uses message token_count to split output tokens across multi-turn sessions', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-token-count', outputTokens: 100 }) + insertMessage(db, { + id: 1, + sessionId: 'sess-token-count', + role: 'assistant', + content: 'short visible text', + tokenCount: 25, + timestamp: 1779345600, + }) + insertMessage(db, { + id: 2, + sessionId: 'sess-token-count', + role: 'assistant', + content: 'another short visible text', + tokenCount: 75, + timestamp: 1779345601, + }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'sess-token-count') + + expect(calls.map(call => call.outputTokens)).toEqual([25, 75]) + }) + + it('does not request cached cost preservation for locally calculated costs', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { + id: 'sess-calculated-cost', + model: 'claude-sonnet-4-6', + inputTokens: 1000, + outputTokens: 100, + }) + insertMessage(db, { id: 1, sessionId: 'sess-calculated-cost', role: 'assistant', content: 'done', timestamp: 1779345600 }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'sess-calculated-cost') + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + expect(calls[0]!.preserveCostUSD).toBe(false) + }) + + it('parses child sessions through the discovered root source', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { id: 'root', title: 'Root', inputTokens: 100, outputTokens: 10 }) + insertSession(db, { id: 'child', parentSessionId: 'root', inputTokens: 200, outputTokens: 20 }) + insertMessage(db, { id: 1, sessionId: 'root', role: 'assistant', content: 'root answer', timestamp: 1779345600 }) + insertMessage(db, { id: 2, sessionId: 'child', role: 'assistant', content: 'child answer', timestamp: 1779345660 }) + }) + + const calls = await collect(createHermesProvider(join(tmpDir, '.hermes')), dbPath, 'root') + + expect(calls).toHaveLength(2) + expect(calls.map(call => call.sessionId)).toEqual(['root', 'root']) + expect(calls.map(call => call.deduplicationKey)).toEqual([ + 'hermes:root:1', + 'hermes:child:2', + ]) + expect(calls.map(call => call.inputTokens)).toEqual([100, 200]) + expect(calls.map(call => call.outputTokens)).toEqual([10, 20]) + }) + + it('deduplicates calls across parser runs', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-dedup', inputTokens: 100, outputTokens: 10 }) + insertMessage(db, { id: 1, sessionId: 'sess-dedup', role: 'assistant', content: 'done', timestamp: 1779345600 }) + }) + + const provider = createHermesProvider(join(tmpDir, '.hermes')) + const seenKeys = new Set() + const first = await collect(provider, dbPath, 'sess-dedup', seenKeys) + const second = await collect(provider, dbPath, 'sess-dedup', seenKeys) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + expect(seenKeys.has('hermes:sess-dedup:1')).toBe(true) + }) +})