Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution".

### Fixed (CLI)
- **Copilot CLI sessions report their input and cache tokens.** The Copilot CLI writes the same `producer: 'copilot-agent'` in its `session.start` events that VS Code transcripts carry, so content-based detection classified every CLI session as a transcript and skipped its `session.shutdown` rollup — the only place the CLI records input, cache-read and cache-write tokens — leaving cache hit rate at 0.0% and dramatically underreporting cost. Whether a file is a transcript is now decided by where discovery found it, never by its contents. Resumed sessions, whose legs each append a cumulative rollup, are billed as per-leg deltas so a growing session never double-counts or goes stale; the GitHub Copilot desktop app writes the same session store, so its usage is covered by the same fix. The copilot session cache takes a parse-version bump and the daily cache bumps from v16 to v17 for the one-time re-parse that heals already-recorded days whose logs still exist. (#944)
- **Copilot CLI subagent runs are attributed to their agent.** Newer CLIs announce delegation with `subagent.started`/`subagent.completed` rather than `subagent.selected`, so delegated turns lost their agent label; the label now also clears when the subagent completes instead of bleeding onto the parent's later turns. Rides the #944 re-parse, so already-cached sessions gain the attribution. (#944)
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)

Expand Down
12 changes: 9 additions & 3 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 16: Codex discovery is structural instead of originator-gated
// Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts
// (#944), so days finalized at v16 or earlier carry output-only copilot costs —
// the session.shutdown rollup's input/cache tokens were dropped. Raising
// MIN_SUPPORTED_VERSION forces the one-time re-derivation under the
// provenance-based classification; sourceless days carry forward as-is.
//
// v16: Codex discovery is structural instead of originator-gated
// (#873/#626), so rollouts written by third-party frontends driving
// `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now
// contribute usage that v15 rollups never contained. Those files were rejected
Expand Down Expand Up @@ -67,8 +73,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 16
const MIN_SUPPORTED_VERSION = 16
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down
175 changes: 130 additions & 45 deletions src/providers/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ type SubagentSelectedData = {
agentName: string
agentDisplayName?: string
tools?: string[]
// Present on subagent.started/completed (CLI ≥ ~1.0.7x): the delegation
// tool call that launched the run, used to pair completed with started.
toolCallId?: string
}

// Per-model usage rollup the CLI writes into session.shutdown. inputTokens is
Expand All @@ -217,6 +220,8 @@ type CopilotEvent =
| { type: 'user.message'; data: UserMessageData; timestamp?: string }
| { type: 'assistant.message'; data: AssistantMessageData; timestamp?: string }
| { type: 'subagent.selected'; data: SubagentSelectedData; timestamp?: string }
| { type: 'subagent.started'; data: SubagentSelectedData; timestamp?: string }
| { type: 'subagent.completed'; data: SubagentSelectedData; timestamp?: string }
| { type: 'session.shutdown'; data: SessionShutdownData; timestamp?: string }

type ChatJournalPathSegment = string | number
Expand Down Expand Up @@ -693,57 +698,72 @@ function inferTranscriptModel(lines: string[]): string {
}

// ---------------------------------------------------------------------------
// JSONL parser (handles both regular session-state events and VS Code
// transcript format via session.start { producer: 'copilot-agent' })
// JSONL parser (handles both regular CLI session-state events and the VS Code
// transcript format — the same event vocabulary, but transcripts carry no
// token counts and no session.shutdown rollup)
// ---------------------------------------------------------------------------

/**
* `isTranscript` comes from discovery (where the file lives), never from
* content: the Copilot CLI writes the same session.start producer
* ('copilot-agent') that VS Code transcripts carry, so producer sniffing
* misread every CLI session as a transcript and dropped its session.shutdown
* input/cache rollup (#944).
*/
function createJsonlParser(
source: SessionSource,
seenKeys: Set<string>
seenKeys: Set<string>,
isTranscript: boolean
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const content = await readSessionFile(source.path)
if (!content) return
const sessionId = basename(dirname(source.path))
// CLI session-state files live at <sessionId>/events.jsonl; transcripts
// at transcripts/<sessionId>.jsonl — keying the latter on the parent dir
// would collapse every transcript into one "transcripts" session (and
// one shared dedup namespace).
const sessionId = isTranscript
? basename(source.path, '.jsonl')
: basename(dirname(source.path))
const lines = content.split('\n').filter((l) => l.trim())

// Detect VS Code transcript format: the first session.start event has
// { producer: 'copilot-agent' } and no outputTokens in messages.
let isTranscript = false
let currentModel = ''
let pendingUserMessage = ''
// Track the active subagent for this session (from subagent.selected events).
// Resets when a new subagent is selected.
let currentSubagentType: string | undefined

// First pass: detect format and infer transcript model if needed.
for (const line of lines) {
try {
const ev = JSON.parse(line) as CopilotEvent
if (ev.type === 'session.start') {
const data = ev.data as SessionStartData & { producer?: string }
if (data.producer === 'copilot-agent') {
isTranscript = true
}
break
}
if (ev.type === 'session.model_change') break // regular format
} catch {
continue
}
}
// Subagent attribution. Older CLIs write subagent.selected — sticky
// until replaced, never cleared. CLI ≥ ~1.0.7x brackets each run with
// started/completed instead; runs can nest or overlap, so completed
// removes ONLY its own toolCallId's entry and the label falls back to
// the still-active run (or the sticky selected value) rather than
// wiping attribution for everything in flight.
let selectedSubagentType: string | undefined
const activeSubagents: Array<{ toolCallId: string; name: string }> = []
const currentSubagentType = (): string | undefined =>
activeSubagents[activeSubagents.length - 1]?.name ?? selectedSubagentType

if (isTranscript) {
// Tool-call-id prefix inference seeds the model; it must not gate the
// whole file, or a transcript carrying explicit model info
// (session.model_change / per-message model) but no tool calls would
// yield nothing. Messages that still end up modelless are skipped
// individually below.
currentModel = inferTranscriptModel(lines)
if (!currentModel) return // no toolCallIds to infer model from
}

// Shutdown rollups may lack their own timestamp; remember the last
// stamped event so the supplementary call is never left with an empty
// timestamp, which the date-range filters silently drop.
let lastEventTimestamp = ''

// A resumed session appends one session.shutdown PER LEG, each carrying
// CUMULATIVE per-model totals. Emitting each rollup whole would need the
// cache to update a prior call in place — the durable merge is
// append-only by dedup key — so we emit per-leg DELTAS keyed by
// occurrence instead: re-parses of a growing file append only the new
// leg, and each leg lands on its own timestamp.
const prevShutdownUsage = new Map<string, ShutdownModelUsage>()
const shutdownCountByModel = new Map<string, number>()

for (const line of lines) {
let event: CopilotEvent
try {
Expand All @@ -766,7 +786,34 @@ function createJsonlParser(
}

if (event.type === 'subagent.selected') {
currentSubagentType = (event.data as SubagentSelectedData).agentName
selectedSubagentType = (event.data as SubagentSelectedData).agentName
continue
}

if (event.type === 'subagent.started') {
const data = event.data as SubagentSelectedData
activeSubagents.push({ toolCallId: data.toolCallId ?? '', name: data.agentName })
continue
}

if (event.type === 'subagent.completed') {
const id = (event.data as SubagentSelectedData).toolCallId ?? ''
if (!id) {
// ID-less completion (transitional CLIs that key nothing, like
// subagent.selected): end the most recently started run; explicit
// no-op on an empty stack.
activeSubagents.pop()
continue
}
for (let i = activeSubagents.length - 1; i >= 0; i--) {
if (activeSubagents[i]!.toolCallId === id) {
activeSubagents.splice(i, 1)
break
}
}
// A non-empty id that matches nothing refers to a run we never saw
// start — leave the active runs alone rather than evicting an
// unrelated one.
continue
}

Expand All @@ -783,11 +830,12 @@ function createJsonlParser(
// is gated to the CLI (non-transcript) format, leaving VS Code,
// JetBrains and OTel sources untouched.
//
// We emit one supplementary call per model carrying ONLY the
// input/cache tokens the per-turn events lack; output is excluded so
// the assistant.message output (and its cost) is not double-counted.
// Combined with the per-turn output cost, this yields the full,
// CLI-measured session cost.
// We emit one supplementary call per model PER SHUTDOWN LEG (resumed
// sessions write one cumulative rollup per leg; see the delta
// tracking above) carrying ONLY the input/cache tokens the per-turn
// events lack; output is excluded so the assistant.message output
// (and its cost) is not double-counted. Combined with the per-turn
// output cost, this yields the full, CLI-measured session cost.
if (isTranscript) continue
const shutdownData = event.data as SessionShutdownData
const modelMetrics = shutdownData.modelMetrics
Expand All @@ -801,23 +849,49 @@ function createJsonlParser(
const usage = metrics['usage']
if (!isRecord(usage)) continue

const cacheReadTokens = numberOrZero(usage['cacheReadTokens'])
const cacheWriteTokens = numberOrZero(usage['cacheWriteTokens'])
const reasoningTokens = numberOrZero(usage['reasoningTokens'])
const cumulative: Required<ShutdownModelUsage> = {
inputTokens: numberOrZero(usage['inputTokens']),
outputTokens: numberOrZero(usage['outputTokens']),
cacheReadTokens: numberOrZero(usage['cacheReadTokens']),
cacheWriteTokens: numberOrZero(usage['cacheWriteTokens']),
reasoningTokens: numberOrZero(usage['reasoningTokens']),
}
const prevRaw = prevShutdownUsage.get(model)
prevShutdownUsage.set(model, cumulative)
const n = (shutdownCountByModel.get(model) ?? 0) + 1
shutdownCountByModel.set(model, n)

// A cumulative total BELOW the previous rollup means the CLI reset
// its counters (a fresh accounting epoch): delta from zero, else
// this leg's post-reset usage would be clamped away entirely.
// inputTokens is the monotonic sentinel — it is cache-inclusive,
// so any usage at all grows it.
const prev =
prevRaw && cumulative.inputTokens < numberOrZero(prevRaw.inputTokens)
? undefined
: prevRaw

// This leg's contribution: cumulative minus the previous rollup.
// The clamp guards any remaining non-monotonic field.
const delta = (k: keyof ShutdownModelUsage): number =>
Math.max(0, cumulative[k] - numberOrZero(prev?.[k]))
const cacheReadTokens = delta('cacheReadTokens')
const cacheWriteTokens = delta('cacheWriteTokens')
const reasoningTokens = delta('reasoningTokens')
// usage.inputTokens is cache-INCLUSIVE (input + cache_read +
// cache_write). calculateCost expects the uncached input alone with
// cache tokens billed separately, so subtract the cache components.
// Clamp at 0 in case a future schema reports input non-inclusively.
const inputTokens = Math.max(
0,
numberOrZero(usage['inputTokens']) - cacheReadTokens - cacheWriteTokens
delta('inputTokens') - cacheReadTokens - cacheWriteTokens
)

// Nothing this call would add over the per-turn events, so skip it
// to avoid an empty $0 row (output is intentionally excluded).
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0 && reasoningTokens === 0) continue

const dedupKey = `copilot:${sessionId}:shutdown:${model}`
const dedupKey = `copilot:${sessionId}:shutdown:${model}:${n}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)

Expand Down Expand Up @@ -898,6 +972,7 @@ function createJsonlParser(
// Cost will be lower than actual API cost. This is the original
// behaviour — OTel data (below) replaces it when available.
const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0)
const subagentType = currentSubagentType()

yield {
provider: 'copilot',
Expand All @@ -914,7 +989,7 @@ function createJsonlParser(
tools,
bashCommands,
skills: skills.length > 0 ? skills : undefined,
subagentTypes: currentSubagentType ? [currentSubagentType] : undefined,
subagentTypes: subagentType ? [subagentType] : undefined,
timestamp: event.timestamp ?? '',
speed: 'standard' as const,
deduplicationKey: dedupKey,
Expand Down Expand Up @@ -1837,6 +1912,12 @@ interface JsonlSessionSource extends SessionSource {
sourceType: 'jsonl'
}

// A VS Code workspaceStorage transcript. Distinct from 'jsonl' (CLI
// session-state) so classification rides provenance, not file contents (#944).
interface TranscriptSessionSource extends SessionSource {
sourceType: 'transcript'
}

interface ChatSessionSource extends SessionSource {
sourceType: 'chatsession'
}
Expand Down Expand Up @@ -1874,6 +1955,10 @@ function isJetBrainsSource(source: SessionSource): source is JetBrainsSessionSou
return (source as JetBrainsSessionSource).sourceType === 'jetbrains'
}

function isTranscriptSource(source: SessionSource): source is TranscriptSessionSource {
return (source as TranscriptSessionSource).sourceType === 'transcript'
}

// ---------------------------------------------------------------------------
// Session discovery: JSONL (original)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2242,8 +2327,8 @@ async function discoverEmptyWindowChatSessions(
*/
async function discoverTranscriptSessions(
workspaceStorageDirs: string[]
): Promise<JsonlSessionSource[]> {
const sources: JsonlSessionSource[] = []
): Promise<TranscriptSessionSource[]> {
const sources: TranscriptSessionSource[] = []

for (const wsDir of workspaceStorageDirs) {
let hashDirs: string[]
Expand Down Expand Up @@ -2275,7 +2360,7 @@ async function discoverTranscriptSessions(
path: join(transcriptsDir, file),
project,
provider: 'copilot',
sourceType: 'jsonl',
sourceType: 'transcript',
})
}
}
Expand Down Expand Up @@ -2418,7 +2503,7 @@ export function createCopilotProvider(
if (isJetBrainsSource(source)) {
return createJetBrainsParser(source, seenKeys)
}
return createJsonlParser(source, seenKeys)
return createJsonlParser(source, seenKeys, isTranscriptSource(source))
},
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'cli-shutdown-cost-v1-skills',
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
// transcripts (both carry producer 'copilot-agent'), skipping the shutdown
// input/cache rollup; this bump re-parses them so the missing tokens land.
copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1',
grok: 'estimated-cost-v1',
hermes: 'reasoning-output-accounting-v1-est-cost',
'lingtai-tui': 'token-ledger-registry-activity-v3',
Expand Down
Loading
Loading