diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index cb93ebd..309b29e 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -11,6 +11,7 @@ type HermesSessionRow = { id: string source: string | null model: string | null + cwd: string | null billing_provider: string | null input_tokens: number | null output_tokens: number | null @@ -294,6 +295,7 @@ async function discoverFromDb(dbPath: string, profile: string): Promise, hermesHome: `SELECT id, ${nullableColumn(columns, 'source')}, ${nullableColumn(columns, 'model')}, + ${nullableColumn(columns, 'cwd')}, ${nullableColumn(columns, 'billing_provider')}, ${numberColumn(columns, 'input_tokens')}, ${numberColumn(columns, 'output_tokens')}, @@ -374,7 +377,13 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: const model = row.model ?? 'unknown' const { tools, toolSequence, bashCommands } = collectTools(messages) - const projectInfo = inferProject(messages, sanitizeProject(profile)) + // Hermes records the session's working directory in sessions.cwd. + // Prefer it; fall back to scraping a "Current working directory:" line + // from the transcript (older builds), then to the profile name. + const cwd = row.cwd?.trim() + const projectInfo = cwd + ? { project: sanitizeProject(cwd), projectPath: cwd } + : inferProject(messages, sanitizeProject(profile)) const timestamp = parseTimestamp(row.started_at) const dedupKey = `hermes:${profile}:${row.id}` if (seenKeys.has(dedupKey)) return @@ -391,10 +400,14 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: cacheReadTokens, 0, ) - const costUSD = + const recordedCost = (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! - : calculatedCost + : null + // When Hermes stored no cost (e.g. subscription-billed sessions), the + // figure is our LiteLLM-priced estimate from the session token totals. + const costUSD = recordedCost ?? calculatedCost + const costIsEstimated = recordedCost === null result = { provider: 'hermes', @@ -407,6 +420,7 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: reasoningTokens, webSearchRequests: 0, costUSD, + costIsEstimated, tools, bashCommands, timestamp, @@ -420,6 +434,9 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: projectPath: projectInfo.projectPath, } } catch (err) { + // A transient lock on the live state.db must propagate so the caller + // retries, not get swallowed into an empty (negatively cached) result. + if (isSqliteBusyError(err)) throw err process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) return } finally { diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts index d3b8aa6..e475bf0 100644 --- a/tests/providers/hermes.test.ts +++ b/tests/providers/hermes.test.ts @@ -48,6 +48,7 @@ function createHermesDb(homeDir: string): string { id TEXT PRIMARY KEY, source TEXT, model TEXT, + cwd TEXT, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, @@ -120,6 +121,7 @@ function insertSession(db: TestDb, values: { id: string source?: string model?: string + cwd?: string | null billingProvider?: string inputTokens: number outputTokens: number @@ -135,14 +137,15 @@ function insertSession(db: TestDb, values: { }): void { db.prepare( `INSERT INTO sessions ( - id, source, model, billing_provider, input_tokens, output_tokens, + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( values.id, values.source ?? 'cli', values.model ?? 'gpt-5.5', + values.cwd ?? null, values.billingProvider ?? 'openai-codex', values.inputTokens, values.outputTokens, @@ -477,4 +480,67 @@ skipUnlessSqlite('hermes provider', () => { 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') + }) })