codeburn/src/guard/usage.ts
Resham Joshi 4307542b62
feat(claude): account for /advisor escalations under the advisor model (#659)
* feat(claude): account for /advisor escalations under the advisor model

Claude Code's advisor tool (/advisor) escalates hard decisions mid-turn
to a stronger model. Those tokens are recorded inside
message.usage.iterations as advisor_message entries with their own model
and are excluded from the top-level message.usage totals. The parser read
only the top-level totals, so advisor spend was silently dropped and never
attributed to the advisor model.

Emit a separate call per advisor_message iteration priced under the
advisor model, preserve those iterations through entry compaction and the
large-line (>32KB) byte-scanner path, and fold advisor cost into the guard
budget. Bump the claude parser and guard cache versions so existing caches
re-parse and pick up the advisor spend.

* test(guard): pin advisor cost fold in the live budget

The guard's advisor fold in computeSessionUsage had no test; dropping
advisorCost from the line cost left all 30 guard tests green. Pins the
fold (advisor line costs more than the same line without iterations)
and the per-message replace semantics under streaming rewrites.
2026-07-16 07:42:49 -07:00

163 lines
6.4 KiB
TypeScript

import { mkdir, readFile, stat, writeFile } from 'fs/promises'
import { readSessionLines } from '../fs-utils.js'
import { parseApiCall, parseAdvisorCalls, parseJsonlLine } from '../parser.js'
import { EDIT_TOOLS } from '../classifier.js'
import { allowPath, sessionCachePath, sessionsDir } from './store.js'
// Per-session running totals. The transcript is append-only, so each invocation
// streams only the bytes after `byteOffset` (the offset of the last complete
// line parsed) and folds them into the totals; a cold parse of a multi-hundred-
// MB transcript on every tool call is what this avoids.
//
// Claude Code rewrites each assistant message several times as it streams, and
// every copy carries the full final usage. The shipped parser dedupes those
// copies last-wins (dedupeStreamingMessageIds); a plain sum here measured real
// sessions at ~3x their true cost. `perMessage` maps message id -> that id's
// current cost contribution, and each id-carrying line REPLACES its previous
// contribution instead of adding. This also self-heals the trailing-line case:
// a complete final line without its newline is folded but byteOffset stops
// before it, so the next invocation re-reads it as a replace, not a double add.
export type GuardCache = {
version: number
sessionId: string
byteOffset: number
costUSD: number
perMessage: Record<string, number>
sawEdit: boolean
sawGitCommit: boolean
lastTurnAt: string | null
updatedAt: string
softWarned: boolean
stopNotified: boolean
}
// v2: per-message-id replace fold (perMessage map, sawEdit boolean) and the
// guard/sessions/ cache location. v1 caches are ignored and cold-reparse once.
// v3: fold advisor (/advisor) cost into each message; v2 caches computed the
// per-message cost without it, so they must cold-reparse.
export const GUARD_CACHE_VERSION = 3
// `commit` must be the git subcommand: `git`, optionally flag tokens (long
// flags, or a short flag with an optional separate value like `-c k=v`), then
// `commit` as the next word, anchored at a command boundary: string/line start
// (multi-line Bash calls separate commands with newlines) or ; & |. The gaps
// inside the command never cross a newline, so "git diff\ncommit msg" is two
// commands, not a commit. "git log --grep commit" and "git diff && echo
// commit" don't match either.
const GIT_COMMIT = /(?:^|[;&|])[^\S\n]*git(?:[^\S\n]+(?:--\S+|-\w+(?:[^\S\n]+\S+)?))*[^\S\n]+commit(?![-\w])/m
export function emptyCache(sessionId: string): GuardCache {
return {
version: GUARD_CACHE_VERSION,
sessionId,
byteOffset: 0,
costUSD: 0,
perMessage: {},
sawEdit: false,
sawGitCommit: false,
lastTurnAt: null,
updatedAt: '',
softWarned: false,
stopNotified: false,
}
}
export async function readCache(sessionId: string, base?: string): Promise<GuardCache> {
let raw: string
try {
raw = await readFile(sessionCachePath(sessionId, base), 'utf-8')
} catch {
return emptyCache(sessionId)
}
try {
const parsed = JSON.parse(raw) as Partial<GuardCache>
if (
parsed.version !== GUARD_CACHE_VERSION
|| typeof parsed.byteOffset !== 'number'
|| !parsed.perMessage || typeof parsed.perMessage !== 'object'
) {
return emptyCache(sessionId)
}
return { ...emptyCache(sessionId), ...parsed, sessionId }
} catch {
return emptyCache(sessionId)
}
}
export async function writeCache(cache: GuardCache, base?: string): Promise<void> {
await mkdir(sessionsDir(base), { recursive: true })
await writeFile(sessionCachePath(cache.sessionId, base), JSON.stringify(cache), 'utf-8')
}
// Fold the transcript tail into the totals. Reuses the streaming line reader
// (startByteOffset + a lastCompleteLineOffset tracker) and the shared per-call
// cost/pricing path (parseApiCall -> calculateCost), so the guard never
// reimplements cost math. `resumedFrom` is the offset the parse restarted at,
// which the test asserts to prove only the tail was read.
export async function computeSessionUsage(
prev: GuardCache,
transcriptPath: string,
): Promise<{ cache: GuardCache; resumedFrom: number }> {
let size: number
try {
size = (await stat(transcriptPath)).size
} catch {
return { cache: prev, resumedFrom: prev.byteOffset }
}
// A shorter file than we last read means the transcript was rotated or
// truncated; start over from a clean total rather than trusting a stale
// offset into different bytes.
const cache = size < prev.byteOffset
? { ...emptyCache(prev.sessionId), softWarned: prev.softWarned, stopNotified: prev.stopNotified }
: { ...prev, perMessage: { ...prev.perMessage } }
const resumedFrom = cache.byteOffset
const tracker = { lastCompleteLineOffset: resumedFrom }
for await (const line of readSessionLines(transcriptPath, undefined, {
startByteOffset: resumedFrom,
byteOffsetTracker: tracker,
largeLineAsBuffer: true,
})) {
const entry = parseJsonlLine(line)
if (!entry) continue
const call = parseApiCall(entry)
if (!call) continue
// Advisor (/advisor) escalations are billed under a separate model and live
// outside the top-level usage, so add their cost to the message total.
const advisorCost = parseAdvisorCalls(entry).reduce((sum, a) => sum + a.costUSD, 0)
const lineCost = call.costUSD + advisorCost
// Last-wins per message id, matching the shipped dedupeStreamingMessageIds.
// Lines without an id (rare, and never streamed in copies) just add.
const msgId = (entry.message as { id?: string } | undefined)?.id
if (msgId) {
cache.costUSD += lineCost - (cache.perMessage[msgId] ?? 0)
cache.perMessage[msgId] = lineCost
} else {
cache.costUSD += lineCost
}
for (const tc of call.toolSequence?.flat() ?? []) {
if (!cache.sawEdit && EDIT_TOOLS.has(tc.tool)) cache.sawEdit = true
if (!cache.sawGitCommit && tc.command && GIT_COMMIT.test(tc.command)) cache.sawGitCommit = true
}
if (call.timestamp) cache.lastTurnAt = call.timestamp
}
cache.byteOffset = tracker.lastCompleteLineOffset
cache.updatedAt = new Date().toISOString()
return { cache, resumedFrom }
}
export async function isAllowed(sessionId: string, base?: string): Promise<boolean> {
try {
await stat(allowPath(sessionId, base))
return true
} catch {
return false
}
}
export async function writeAllow(sessionId: string, base?: string): Promise<void> {
await mkdir(sessionsDir(base), { recursive: true })
await writeFile(allowPath(sessionId, base), '', 'utf-8')
}