fix(hermes): use sessions.cwd, flag estimated cost, propagate SQLITE_BUSY

- Read the populated sessions.cwd column for project grouping; fall back to
  scraping the transcript, then the profile name. The current Hermes build no
  longer writes "Current working directory:" lines into messages.
- Set costIsEstimated when the figure is the LiteLLM fallback (no recorded cost).
- Re-throw SQLITE_BUSY from the discovery and read paths so a transient lock on
  the live state.db is retried instead of being cached as an empty result.
- Tests: add the cwd column to fixtures; cover cwd grouping, the estimated flag,
  and tool-result tool_name extraction.
This commit is contained in:
AgentSeal 2026-06-21 23:50:10 +02:00
parent 645b3c2bdd
commit e80da3139a
2 changed files with 88 additions and 5 deletions

View file

@ -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<SessionS
provider: 'hermes',
}))
} catch (err) {
if (isSqliteBusyError(err)) throw err
process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`)
return []
} finally {
@ -329,6 +331,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, 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<string>, 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<string>, 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<string>, hermesHome:
reasoningTokens,
webSearchRequests: 0,
costUSD,
costIsEstimated,
tools,
bashCommands,
timestamp,
@ -420,6 +434,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>, 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 {

View file

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