feat(capture): parse-time capture of branch, LOC deltas, interruptions, titles, PR links

This commit is contained in:
iamtoruk 2026-07-20 05:56:38 -07:00
parent 2312fd2eb0
commit 8ced2ed47f
9 changed files with 743 additions and 15 deletions

View file

@ -11,7 +11,9 @@ import type { ParsedProviderCall } from './providers/types.js'
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
// so sessions cached under v4 pick up the CLI-MCP attribution.
const CODEX_CACHE_VERSION = 5
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
const CODEX_CACHE_VERSION = 6
const CACHE_FILE = 'codex-results.json'
type FileFingerprint = { mtimeMs: number; sizeBytes: number }

View file

@ -524,7 +524,7 @@ function extractObjectFields(
return captured
}
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'attachment', 'message'] as const
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message'] as const
const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const
function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
@ -541,9 +541,11 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
const timestamp = readJsonString(source, root['timestamp'])
const sessionId = readJsonString(source, root['sessionId'])
const cwd = readJsonString(source, root['cwd'])
const gitBranch = readJsonString(source, root['gitBranch'])
if (timestamp !== undefined) entry.timestamp = timestamp
if (sessionId !== undefined) entry.sessionId = sessionId
if (cwd !== undefined) entry.cwd = cwd
if (gitBranch !== undefined) entry.gitBranch = gitBranch
const addedNames = extractLargeAddedNames(source, root['attachment'])
if (addedNames.length > 0) {
;(entry as Record<string, unknown>)['attachment'] = { type: 'deferred_tools_delta', addedNames }
@ -907,6 +909,8 @@ export function compactEntry(raw: JournalEntry): JournalEntry {
if (raw.timestamp !== undefined) entry.timestamp = raw.timestamp
if (raw.sessionId !== undefined) entry.sessionId = raw.sessionId
if (raw.cwd !== undefined) entry.cwd = raw.cwd
// Preserved so groupIntoTurns can stamp each turn's git branch (rich capture).
if (typeof raw.gitBranch === 'string' && raw.gitBranch) entry.gitBranch = raw.gitBranch
const att = (raw as Record<string, unknown>)['attachment']
if (att && typeof att === 'object') {
@ -1151,7 +1155,97 @@ function applyLocalModelSavings(call: ParsedApiCall): ParsedApiCall {
}
}
export function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
// ── Rich Session Capture (Claude) ──────────────────────────────────────
//
// Parse-time extraction of edit sizes, interruptions, error counts, git branch,
// and session titles/PR links from the raw JSONL. Capture-only: no report or
// payload consumes these yet. Everything is optional and omitted at zero/false
// to keep the cache cost minimal.
// Per-call metadata keyed by tool_use_id, built from a session's user
// (tool-result) entries before compaction discards `toolUseResult` and the
// tool_result blocks' `is_error` flag.
export type ToolResultMeta = {
locAdded: number
locRemoved: number
interrupted: boolean
userModified: boolean
isError: boolean
}
// Session-level accumulator: last `ai-title` wins, `pr-link` URLs accumulate,
// and any sidechain entry flips `isSidechain`. parentUuid is deliberately not
// captured as a session link — it references an intra-file entry uuid, not
// another session's id, so it cannot reliably connect two sessions.
export type SessionMeta = {
title?: string
prLinks: string[]
isSidechain: boolean
}
export function emptySessionMeta(): SessionMeta {
return { prLinks: [], isSidechain: false }
}
// Count added/removed lines from a Claude `toolUseResult.structuredPatch`. Each
// hunk's `lines` array holds unified-diff content lines: a leading '+' is an
// added line, '-' a removed line, ' ' context. Numbers only — patch text is
// never stored. Missing/empty/non-array patches count as zero.
export function countStructuredPatchLoc(patch: unknown): { added: number; removed: number } {
let added = 0
let removed = 0
if (!Array.isArray(patch)) return { added, removed }
for (const hunk of patch) {
const lines = (hunk as { lines?: unknown } | null)?.lines
if (!Array.isArray(lines)) continue
for (const line of lines) {
if (typeof line !== 'string') continue
if (line.startsWith('+')) added++
else if (line.startsWith('-')) removed++
}
}
return { added, removed }
}
// Record tool-result metadata from a raw user entry into `map`, keyed by the
// tool_result block's tool_use_id. Must run on the RAW entry (before
// compactEntry drops toolUseResult / is_error). Large tool-result lines parsed
// as buffers lose toolUseResult (the byte scanner does not extract it) — an
// accepted gap for oversized outputs.
export function collectToolResultMeta(entry: JournalEntry, map: Map<string, ToolResultMeta>): void {
if (entry.type !== 'user') return
const msg = entry.message
const content = msg && typeof msg === 'object' ? (msg as { content?: unknown }).content : undefined
if (!Array.isArray(content)) return
const tur = (entry as Record<string, unknown>)['toolUseResult']
const turObj = tur && typeof tur === 'object' ? tur as Record<string, unknown> : undefined
const loc = countStructuredPatchLoc(turObj?.['structuredPatch'])
const interrupted = turObj?.['interrupted'] === true
const userModified = turObj?.['userModified'] === true
for (const b of content) {
if (!b || typeof b !== 'object' || (b as { type?: unknown }).type !== 'tool_result') continue
const id = (b as { tool_use_id?: unknown }).tool_use_id
if (typeof id !== 'string' || !id) continue
const isError = (b as { is_error?: unknown }).is_error === true
map.set(id, { locAdded: loc.added, locRemoved: loc.removed, interrupted, userModified, isError })
}
}
// Accumulate session-level metadata from a raw entry. `ai-title` is last-wins
// (Claude refines the title over the session); `pr-link` URLs union; any
// sidechain entry marks the session.
export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void {
if (entry.type === 'ai-title') {
const t = (entry as Record<string, unknown>)['aiTitle']
if (typeof t === 'string' && t.trim()) meta.title = t.trim().slice(0, 200)
} else if (entry.type === 'pr-link') {
const url = (entry as Record<string, unknown>)['prUrl']
if (typeof url === 'string' && url && !meta.prLinks.includes(url)) meta.prLinks.push(url)
}
if (entry.isSidechain === true) meta.isSidechain = true
}
export function parseApiCall(entry: JournalEntry, toolResultMeta?: Map<string, ToolResultMeta>): ParsedApiCall | null {
if (entry.type !== 'assistant') return null
const msg = entry.message as AssistantMessageContent | undefined
if (!msg?.usage || !msg?.model) return null
@ -1198,6 +1292,27 @@ export function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
return [call]
})
// Attribute tool-result metadata (edit LOC, interruptions, errors) to this
// call by summing over the tool_use ids it issued. Omitted entirely when no
// meta map is supplied (e.g. the guard usage path) or nothing was recorded.
let locAdded = 0
let locRemoved = 0
let toolErrors = 0
let interrupted = false
let userModified = false
if (toolResultMeta && toolResultMeta.size > 0) {
for (const b of contentBlocks) {
if (b.type !== 'tool_use') continue
const m = toolResultMeta.get((b as ToolUseBlock).id)
if (!m) continue
locAdded += m.locAdded
locRemoved += m.locRemoved
if (m.isError) toolErrors++
if (m.interrupted) interrupted = true
if (m.userModified) userModified = true
}
}
return applyLocalModelSavings({
provider: 'claude',
model: msg.model,
@ -1215,6 +1330,11 @@ export function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
deduplicationKey: msg.id ?? `claude:${entry.timestamp}`,
cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined,
toolSequence: toolSeq.length > 0 ? toolSeq : undefined,
...(locAdded ? { locAdded } : {}),
...(locRemoved ? { locRemoved } : {}),
...(interrupted ? { interrupted: true } : {}),
...(userModified ? { userModified: true } : {}),
...(toolErrors ? { toolErrors } : {}),
})
}
@ -1309,14 +1429,19 @@ export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry
return result
}
function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): ParsedTurn[] {
export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>, toolResultMeta?: Map<string, ToolResultMeta>): ParsedTurn[] {
const turns: ParsedTurn[] = []
let currentUserMessage = ''
let currentCalls: ParsedApiCall[] = []
let currentTimestamp = ''
let currentSessionId = ''
// Git branch of the turn currently being accumulated. Captured at turn start
// from the user entry (gitBranch is on every user/assistant entry); a
// continuation turn with no leading user text falls back to its first call.
let currentBranch: string | undefined
for (const entry of entries) {
const entryBranch = typeof entry.gitBranch === 'string' && entry.gitBranch ? entry.gitBranch : undefined
if (entry.type === 'user') {
const text = getUserMessageText(entry)
if (text.trim()) {
@ -1326,18 +1451,21 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): Parse
assistantCalls: currentCalls,
timestamp: currentTimestamp,
sessionId: currentSessionId,
...(currentBranch ? { gitBranch: currentBranch } : {}),
})
}
currentUserMessage = text
currentCalls = []
currentTimestamp = entry.timestamp ?? ''
currentSessionId = entry.sessionId ?? ''
currentBranch = entryBranch
}
} else if (entry.type === 'assistant') {
if (entryBranch && !currentBranch) currentBranch = entryBranch
const msgId = getMessageId(entry)
if (msgId && seenMsgIds.has(msgId)) continue
if (msgId) seenMsgIds.add(msgId)
const call = parseApiCall(entry)
const call = parseApiCall(entry, toolResultMeta)
if (call) currentCalls.push(call)
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
}
@ -1349,6 +1477,7 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): Parse
assistantCalls: currentCalls,
timestamp: currentTimestamp,
sessionId: currentSessionId,
...(currentBranch ? { gitBranch: currentBranch } : {}),
})
}
@ -1731,11 +1860,13 @@ async function scanProjectDirs(
// JSONL, this is the dominant warm-run cost. The merged result is
// byte-for-byte identical to a full re-parse (see mergeBoundaryCalls).
const tracker = { lastCompleteLineOffset: append.readFromOffset }
const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset)
const toolResultMeta = new Map<string, ToolResultMeta>()
const sessionMeta = emptySessionMeta()
const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset, { toolResultMeta, sessionMeta })
const cached = append.cached
const newTurns = newEntries
? groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds).map(parsedTurnToCachedTurn)
? parsedTurnsToCachedTurns(groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds, toolResultMeta))
: []
const mergedTurns: CachedTurn[] = cached.turns.map(t => ({ ...t, calls: [...t.calls] }))
@ -1773,6 +1904,12 @@ async function scanProjectDirs(
? Array.from(new Set([...cached.mcpInventory, ...extractMcpInventory(newEntries)])).sort()
: cached.mcpInventory
// Session meta merges across the append boundary: title is last-wins
// (prefer the newly-parsed tail), PR links union, isSidechain is sticky.
const mergedTitle = sessionMeta.title ?? cached.title
const mergedPrLinks = Array.from(new Set([...(cached.prLinks ?? []), ...sessionMeta.prLinks]))
const mergedSidechain = cached.isSidechain === true || sessionMeta.isSidechain
section.files[filePath] = {
fingerprint: info.fp,
lastCompleteLineOffset: tracker.lastCompleteLineOffset,
@ -1781,6 +1918,9 @@ async function scanProjectDirs(
mcpInventory,
turns: mergedTurns,
agentType: cached.agentType,
...(mergedTitle ? { title: mergedTitle } : {}),
...(mergedPrLinks.length > 0 ? { prLinks: mergedPrLinks } : {}),
...(mergedSidechain ? { isSidechain: true } : {}),
}
;(diskCache as { _dirty?: boolean })._dirty = true
filesDone++
@ -1793,10 +1933,12 @@ async function scanProjectDirs(
}
const tracker = { lastCompleteLineOffset: 0 }
const entries = await parseClaudeEntries(filePath, tracker)
const toolResultMeta = new Map<string, ToolResultMeta>()
const sessionMeta = emptySessionMeta()
const entries = await parseClaudeEntries(filePath, tracker, undefined, { toolResultMeta, sessionMeta })
if (!entries) { filesDone++; await parseProgress.tick(filesDone); continue }
const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds)
const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds, toolResultMeta)
const cwd = extractCanonicalCwd(entries)
const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined
section.files[filePath] = {
@ -1805,8 +1947,11 @@ async function scanProjectDirs(
canonicalCwd: canonical?.path,
canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined,
mcpInventory: extractMcpInventory(entries),
turns: turns.map(parsedTurnToCachedTurn),
turns: parsedTurnsToCachedTurns(turns),
agentType: await readAgentType(filePath),
...(sessionMeta.title ? { title: sessionMeta.title } : {}),
...(sessionMeta.prLinks.length > 0 ? { prLinks: sessionMeta.prLinks } : {}),
...(sessionMeta.isSidechain ? { isSidechain: true } : {}),
}
;(diskCache as { _dirty?: boolean })._dirty = true
} catch (err) {
@ -1997,6 +2142,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
project: call.project,
projectPath: call.projectPath,
toolSequence: call.toolSequence,
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
...(call.editFailed ? { editFailed: call.editFailed } : {}),
}
}
@ -2027,6 +2175,11 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
subagentTypes: call.subagentTypes,
deduplicationKey: call.deduplicationKey,
toolSequence: call.toolSequence,
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
...(call.interrupted ? { interrupted: true } : {}),
...(call.userModified ? { userModified: true } : {}),
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
}
}
@ -2039,6 +2192,23 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
}
}
// Convert a batch of parsed turns to cached turns, storing each turn's gitBranch
// only when it differs from the previous turn's branch in this batch. A report
// reconstructs a turn's branch by carrying the last stored value forward. The
// dedup is per-batch, so the first turn of an appended region always restates
// its branch (harmless: a redundant restatement, never a wrong value).
export function parsedTurnsToCachedTurns(turns: ParsedTurn[]): CachedTurn[] {
const out: CachedTurn[] = []
let prevBranch: string | undefined
for (const turn of turns) {
const cached = parsedTurnToCachedTurn(turn)
if (turn.gitBranch && turn.gitBranch !== prevBranch) cached.gitBranch = turn.gitBranch
if (turn.gitBranch) prevBranch = turn.gitBranch
out.push(cached)
}
return out
}
function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
return {
timestamp: call.timestamp,
@ -2166,6 +2336,9 @@ async function parseClaudeEntries(
filePath: string,
tracker: { lastCompleteLineOffset: number },
startByteOffset?: number,
// Rich-capture collectors, populated from the RAW entry before compaction
// strips toolUseResult / ai-title / pr-link / isSidechain.
collectors?: { toolResultMeta?: Map<string, ToolResultMeta>; sessionMeta?: SessionMeta },
): Promise<JournalEntry[] | null> {
const entries: JournalEntry[] = []
let hasLines = false
@ -2176,7 +2349,10 @@ async function parseClaudeEntries(
})) {
hasLines = true
const entry = parseJsonlLine(line)
if (entry) entries.push(compactEntry(entry))
if (!entry) continue
if (collectors?.toolResultMeta) collectToolResultMeta(entry, collectors.toolResultMeta)
if (collectors?.sessionMeta) collectSessionMeta(entry, collectors.sessionMeta)
entries.push(compactEntry(entry))
}
if (!hasLines || entries.length === 0) return null
return entries

View file

@ -76,6 +76,21 @@ function mcpToolFromShellCommand(command: unknown): string | null {
return `mcp__${server}__${tool}`
}
// Count added/removed lines from a Codex `patch_apply_end` change's
// `unified_diff`. A leading '+' is an added line and '-' a removed line; the
// '+++'/'---' file headers and '@@' hunk headers are excluded. Numbers only —
// the diff text is never stored. Rich-session-capture (capture-only).
export function countUnifiedDiffLoc(diff: unknown): { added: number; removed: number } {
let added = 0
let removed = 0
if (typeof diff !== 'string') return { added, removed }
for (const line of diff.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) added++
else if (line.startsWith('-') && !line.startsWith('---')) removed++
}
return { added, removed }
}
type CodexEntry = {
type: string
timestamp?: string
@ -384,6 +399,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
let pendingToolSequence: ToolCall[][] = []
let pendingUserMessage = ''
let pendingOutputChars = 0
// Rich-session-capture: edit LOC deltas and failed-patch count accumulated
// across a turn's patch_apply_end events, flushed onto the turn's call.
let pendingLocAdded = 0
let pendingLocRemoved = 0
let pendingEditFailed = 0
let estCounter = 0
let turnCounter = 0
let currentTurnId = `${sessionId}:t0`
@ -445,14 +465,21 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
pendingTools.push('Edit')
const p = entry.payload as Record<string, unknown>
const changes = p['changes']
const filePaths = typeof changes === 'object' && changes ? Object.keys(changes as object) : []
const changesObj = typeof changes === 'object' && changes ? changes as Record<string, unknown> : {}
const filePaths = Object.keys(changesObj)
if (filePaths.length > 0) {
for (const fp of filePaths) {
pendingToolSequence.push([{ tool: 'Edit', file: fp }])
const diff = (changesObj[fp] as Record<string, unknown> | undefined)?.['unified_diff']
const loc = countUnifiedDiffLoc(diff)
pendingLocAdded += loc.added
pendingLocRemoved += loc.removed
}
} else {
pendingToolSequence.push([{ tool: 'Edit' }])
}
// Only an explicit failure counts; a missing `success` is treated as ok.
if (p['success'] === false) pendingEditFailed++
continue
}
@ -508,7 +535,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const timestamp = entry.timestamp ?? ''
const dedupKey = `codex:${sessionId}:${timestamp}:est${estCounter++}`
if (seenKeys.has(dedupKey)) { pendingTools = []; pendingToolSequence = []; pendingUserMessage = ''; pendingOutputChars = 0; continue }
if (seenKeys.has(dedupKey)) { pendingTools = []; pendingToolSequence = []; pendingUserMessage = ''; pendingOutputChars = 0; pendingLocAdded = 0; pendingLocRemoved = 0; pendingEditFailed = 0; continue }
seenKeys.add(dedupKey)
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
@ -534,12 +561,18 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
userMessage: pendingUserMessage,
sessionId,
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
pendingTools = []
pendingToolSequence = []
pendingUserMessage = ''
pendingOutputChars = 0
pendingLocAdded = 0
pendingLocRemoved = 0
pendingEditFailed = 0
continue
}
@ -644,12 +677,18 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
userMessage: pendingUserMessage,
sessionId,
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
pendingTools = []
pendingToolSequence = []
pendingUserMessage = ''
pendingOutputChars = 0
pendingLocAdded = 0
pendingLocRemoved = 0
pendingEditFailed = 0
}
}

View file

@ -37,6 +37,13 @@ export type ParsedProviderCall = {
timestamp: string
speed: 'standard' | 'fast'
deduplicationKey: string
// Lines added/removed by this call's edits, counted from the provider's diff
// records (Codex: `patch_apply_end.changes[*].unified_diff`). Numbers only;
// omitted when zero. `editFailed` counts patches with `success === false`.
// Rich-session-capture (capture-only; no report yet).
locAdded?: number
locRemoved?: number
editFailed?: number
turnId?: string
toolSequence?: ToolCall[][]
userMessage: string

View file

@ -37,6 +37,19 @@ export type CachedCall = {
project?: string
projectPath?: string
toolSequence?: ToolCall[][]
// Rich-session-capture (capture-only; no report consumes these yet). All
// optional and omitted at zero/false to keep the per-call cache cost minimal.
// Lines added/removed by this call's edits, counted from tool-result diffs
// (Claude structuredPatch / Codex unified_diff). Numbers only, never patch text.
locAdded?: number
locRemoved?: number
// True only. Claude: a tool result was interrupted / user-modified its edit.
interrupted?: boolean
userModified?: boolean
// Claude: count of this call's tool results flagged is_error. Omitted at 0.
toolErrors?: number
// Codex: count of this call's patch applications with success === false.
editFailed?: number
}
export type CachedTurn = {
@ -44,6 +57,10 @@ export type CachedTurn = {
sessionId: string
userMessage: string
calls: CachedCall[]
// Claude: git branch for this turn, stored only when it differs from the
// previous turn's branch (a report carries the last stored value forward).
// Rich-session-capture; optional, Claude only.
gitBranch?: string
}
export type FileFingerprint = {
@ -69,6 +86,14 @@ export type CachedFile = {
// is re-parsed only when the file changes (fingerprint differs). Carries no
// turns, so it contributes no usage. (issue #441 follow-up)
failed?: boolean
// Rich-session-capture, Claude session-level (capture-only; no report yet).
// `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every
// `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain:
// parentUuid references an intra-file entry uuid, not another session id, so it
// cannot link sessions — only the boolean marker is reliable. All optional.
title?: string
prLinks?: string[]
isSidechain?: boolean
}
export type ProviderSection = {
@ -143,14 +168,21 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
// re-parse, which lands the flag too, and durable orphans now survive
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
claude: 'advisor-usage-v1-skills',
// rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call
// LOC deltas / interruptions / userModified / toolErrors, and session-level
// title / prLinks / isSidechain. Forces one re-parse so cached sessions gain
// the new optional fields.
claude: 'advisor-usage-v1-skills-rich-capture-v1',
cline: 'worktree-project-grouping-v1',
codewhale: 'aggregate-session-v1-est-cost',
// Bump when the Codex parser changes attribution so unchanged, already-cached
// session files re-parse (session-cache.json serves them without invoking the
// provider parser otherwise). Covers native mcp_tool_call_end (#513) and
// CLI-wrapped `mcp-cli call` (#478) MCP attribution.
codex: 'mcp-attribution-v2-est-cost',
// rich-session-capture-v1: per-call LOC deltas + editFailed from
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
// lockstep so the pre-session-cache layer re-parses too.)
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'cli-shutdown-cost-v1-skills',
@ -273,6 +305,12 @@ function validateCall(c: unknown): c is CachedCall {
&& isOptionalString(o['project'])
&& isOptionalString(o['projectPath'])
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
&& isOptionalNum(o['locAdded'])
&& isOptionalNum(o['locRemoved'])
&& isOptionalBool(o['interrupted'])
&& isOptionalBool(o['userModified'])
&& isOptionalNum(o['toolErrors'])
&& isOptionalNum(o['editFailed'])
&& validateUsage(o['usage'])
}
@ -282,6 +320,7 @@ function validateTurn(t: unknown): t is CachedTurn {
return typeof o['timestamp'] === 'string'
&& typeof o['sessionId'] === 'string'
&& typeof o['userMessage'] === 'string'
&& isOptionalString(o['gitBranch'])
&& Array.isArray(o['calls'])
&& (o['calls'] as unknown[]).every(validateCall)
}
@ -294,6 +333,9 @@ function validateCachedFile(f: unknown): f is CachedFile {
&& isOptionalString(o['canonicalCwd'])
&& isOptionalString(o['canonicalProjectName'])
&& isStringArray(o['mcpInventory'])
&& isOptionalString(o['title'])
&& (o['prLinks'] === undefined || isStringArray(o['prLinks']))
&& isOptionalBool(o['isSidechain'])
&& Array.isArray(o['turns'])
&& (o['turns'] as unknown[]).every(validateTurn)
}

View file

@ -90,6 +90,10 @@ export type ParsedTurn = {
assistantCalls: ParsedApiCall[]
timestamp: string
sessionId: string
// Claude Code: the git branch active for this turn (top-level `gitBranch` on
// the turn's entries). Captured for cost-per-branch reporting; deduped at the
// cache boundary (stored per-turn only when it changes). Optional; Claude only.
gitBranch?: string
}
export type ParsedApiCall = {
@ -122,6 +126,19 @@ export type ParsedApiCall = {
/// across the parser/cache boundary. Aggregates roll the estimated portion up
/// as `estimatedCostUSD`; it is display/metadata only and never changes totals.
isEstimated?: boolean
/// Lines added/removed by this call's edits, counted from tool-result diffs
/// (Claude: `toolUseResult.structuredPatch`). Numbers only, never patch text;
/// omitted when zero. Rich-session-capture (capture-only; no report yet).
locAdded?: number
locRemoved?: number
/// True only when at least one of this call's tool results was interrupted or
/// had its edit modified by the user (Claude `toolUseResult.interrupted` /
/// `userModified`). Omitted when false.
interrupted?: boolean
userModified?: boolean
/// Count of this call's tool results flagged `is_error` (Claude tool_result
/// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0.
toolErrors?: number
}
export type ToolCall = {

View file

@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createCodexProvider, countUnifiedDiffLoc } from '../src/providers/codex.js'
import type { ParsedProviderCall } from '../src/providers/types.js'
// ── unified-diff LOC counting ──────────────────────────────────────────
describe('countUnifiedDiffLoc', () => {
it('counts +/- content lines and ignores @@ / +++ / --- headers', () => {
// Shape copied from a real ~/.codex patch_apply_end change (sanitized).
const diff = '@@ -83,3 +83,3 @@\n \n-SCHEMA_VERSION = 12\n+SCHEMA_VERSION = 13\n # cap\n@@ -107,2 +107,6 @@\n \n+class NewError(AssertionError):\n+ pass\n'
expect(countUnifiedDiffLoc(diff)).toEqual({ added: 3, removed: 1 })
})
it('excludes git-style file headers', () => {
const diff = '--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new\n'
expect(countUnifiedDiffLoc(diff)).toEqual({ added: 1, removed: 1 })
})
it('returns zero for non-string input', () => {
expect(countUnifiedDiffLoc(undefined)).toEqual({ added: 0, removed: 0 })
expect(countUnifiedDiffLoc(null)).toEqual({ added: 0, removed: 0 })
})
})
// ── parser-level: patch_apply_end LOC + failed-edit attribution ─────────
let tmpDir: string
beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-rich-')) })
afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) })
function line(obj: unknown): string { return JSON.stringify(obj) }
async function writeSession(dir: string, date: string, filename: string, lines: string[]): Promise<string> {
const [year, month, day] = date.split('-')
const sessionDir = join(dir, 'sessions', year!, month!, day!)
await mkdir(sessionDir, { recursive: true })
const filePath = join(sessionDir, filename)
await writeFile(filePath, lines.join('\n') + '\n')
return filePath
}
async function parseAll(filePath: string): Promise<ParsedProviderCall[]> {
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) calls.push(call)
return calls
}
const patchApplyEnd = (opts: { changes: Record<string, string>; success?: boolean }) => line({
type: 'event_msg',
timestamp: '2026-04-14T10:00:30Z',
payload: {
type: 'patch_apply_end',
success: opts.success ?? true,
changes: Object.fromEntries(Object.entries(opts.changes).map(([f, d]) => [f, { type: 'update', unified_diff: d, move_path: null }])),
},
})
const tokenCount = () => line({
type: 'event_msg',
timestamp: '2026-04-14T10:01:00Z',
payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, total_token_usage: { total_tokens: 150 } } },
})
describe('codex parser - patch_apply_end capture', () => {
it('attaches locAdded/locRemoved summed across changed files', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-loc.jsonl', [
line({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 'sx', model: 'gpt-5.3-codex' } }),
line({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'edit files' }] } }),
patchApplyEnd({ changes: {
'/Users/t/p/a.ts': '@@ -1 +1,2 @@\n-old\n+new\n+extra\n',
'/Users/t/p/b.ts': '@@ -1,2 +1 @@\n-x\n-y\n+z\n',
} }),
tokenCount(),
])
const calls = await parseAll(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.locAdded).toBe(3)
expect(calls[0]!.locRemoved).toBe(3)
expect(calls[0]!.editFailed).toBeUndefined()
})
it('counts a failed patch application', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-fail.jsonl', [
line({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 'sy', model: 'gpt-5.3-codex' } }),
line({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'edit' }] } }),
patchApplyEnd({ success: false, changes: { '/Users/t/p/a.ts': '@@ -1 +1 @@\n-old\n+new\n' } }),
tokenCount(),
])
const calls = await parseAll(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.editFailed).toBe(1)
expect(calls[0]!.locAdded).toBe(1)
expect(calls[0]!.locRemoved).toBe(1)
})
it('omits LOC fields for a turn with no patch', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-nopatch.jsonl', [
line({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 'sz', model: 'gpt-5.3-codex' } }),
line({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] } }),
tokenCount(),
])
const calls = await parseAll(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.locAdded).toBeUndefined()
expect(calls[0]!.locRemoved).toBeUndefined()
expect(calls[0]!.editFailed).toBeUndefined()
})
})

View file

@ -0,0 +1,213 @@
import { describe, it, expect } from 'vitest'
import {
countStructuredPatchLoc,
collectToolResultMeta,
collectSessionMeta,
emptySessionMeta,
parseApiCall,
groupIntoTurns,
parsedTurnsToCachedTurns,
type ToolResultMeta,
} from '../src/parser.js'
import type { JournalEntry } from '../src/types.js'
// ── structuredPatch LOC counting ───────────────────────────────────────
describe('countStructuredPatchLoc', () => {
it('counts +/- lines across a single hunk', () => {
const patch = [
{ oldStart: 80, oldLines: 7, newStart: 80, newLines: 7, lines: [
' unchanged context',
'- old line',
'+ new line',
' more context',
] },
]
expect(countStructuredPatchLoc(patch)).toEqual({ added: 1, removed: 1 })
})
it('sums across multiple hunks', () => {
const patch = [
{ lines: ['+a', '+b', '-c', ' ctx'] },
{ lines: ['+d', '-e', '-f'] },
]
expect(countStructuredPatchLoc(patch)).toEqual({ added: 3, removed: 3 })
})
it('returns zero for an empty patch (Write-create shape)', () => {
expect(countStructuredPatchLoc([])).toEqual({ added: 0, removed: 0 })
})
it('returns zero for a missing/non-array patch', () => {
expect(countStructuredPatchLoc(undefined)).toEqual({ added: 0, removed: 0 })
expect(countStructuredPatchLoc(null)).toEqual({ added: 0, removed: 0 })
expect(countStructuredPatchLoc({ lines: ['+x'] })).toEqual({ added: 0, removed: 0 })
})
it('ignores hunks whose lines are absent or non-string', () => {
const patch = [{ oldStart: 1 }, { lines: [1, '+ok', null, '-no'] }]
expect(countStructuredPatchLoc(patch)).toEqual({ added: 1, removed: 1 })
})
})
// ── tool-result meta extraction + per-call attribution ─────────────────
function assistantEntry(id: string, toolUseIds: string[]): JournalEntry {
return {
type: 'assistant',
timestamp: '2026-07-01T10:00:00Z',
sessionId: 's1',
gitBranch: 'main',
message: {
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-20250514',
id,
content: toolUseIds.map(tid => ({ type: 'tool_use' as const, id: tid, name: 'Edit', input: {} })),
usage: { input_tokens: 10, output_tokens: 5 },
},
}
}
function toolResultEntry(opts: {
toolUseId: string
isError?: boolean
structuredPatch?: unknown
interrupted?: boolean
userModified?: boolean
}): JournalEntry {
return {
type: 'user',
timestamp: '2026-07-01T10:00:01Z',
sessionId: 's1',
gitBranch: 'main',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: opts.toolUseId, is_error: opts.isError, content: 'x' } as never],
},
toolUseResult: {
structuredPatch: opts.structuredPatch,
interrupted: opts.interrupted ?? false,
userModified: opts.userModified ?? false,
},
} as JournalEntry
}
describe('collectToolResultMeta + parseApiCall attribution', () => {
it('attributes LOC, interrupted, userModified, and toolErrors to the issuing call', () => {
const map = new Map<string, ToolResultMeta>()
collectToolResultMeta(toolResultEntry({
toolUseId: 'tu1',
structuredPatch: [{ lines: ['+a', '+b', '-c'] }],
userModified: true,
}), map)
collectToolResultMeta(toolResultEntry({
toolUseId: 'tu2',
isError: true,
interrupted: true,
}), map)
const call = parseApiCall(assistantEntry('m1', ['tu1', 'tu2']), map)
expect(call).not.toBeNull()
expect(call!.locAdded).toBe(2)
expect(call!.locRemoved).toBe(1)
expect(call!.toolErrors).toBe(1)
expect(call!.interrupted).toBe(true)
expect(call!.userModified).toBe(true)
})
it('omits all rich fields when no meta map is supplied', () => {
const call = parseApiCall(assistantEntry('m1', ['tu1']))
expect(call!.locAdded).toBeUndefined()
expect(call!.locRemoved).toBeUndefined()
expect(call!.toolErrors).toBeUndefined()
expect(call!.interrupted).toBeUndefined()
expect(call!.userModified).toBeUndefined()
})
it('omits fields when the map has no matching tool_use id', () => {
const map = new Map<string, ToolResultMeta>()
collectToolResultMeta(toolResultEntry({ toolUseId: 'other', structuredPatch: [{ lines: ['+a'] }] }), map)
const call = parseApiCall(assistantEntry('m1', ['tu1']), map)
expect(call!.locAdded).toBeUndefined()
expect(call!.toolErrors).toBeUndefined()
})
it('counts is_error but not a non-error result with stderr', () => {
// Bash results carry stderr for warnings; only is_error marks a real failure.
const map = new Map<string, ToolResultMeta>()
collectToolResultMeta(toolResultEntry({ toolUseId: 'tu1', isError: false }), map)
const call = parseApiCall(assistantEntry('m1', ['tu1']), map)
expect(call!.toolErrors).toBeUndefined()
})
})
// ── gitBranch representation (per-turn dedup) ──────────────────────────
function userText(text: string, branch: string, sessionId = 's1'): JournalEntry {
return { type: 'user', timestamp: '2026-07-01T10:00:00Z', sessionId, gitBranch: branch, message: { role: 'user', content: text } }
}
function assistant(id: string, branch: string, sessionId = 's1'): JournalEntry {
return {
type: 'assistant', timestamp: '2026-07-01T10:00:02Z', sessionId, gitBranch: branch,
message: { type: 'message', role: 'assistant', model: 'claude-sonnet-4-20250514', id, content: [], usage: { input_tokens: 5, output_tokens: 5 } },
}
}
describe('gitBranch capture + dedup', () => {
it('stores the branch once for a single-branch session', () => {
const entries = [
userText('first', 'main'), assistant('m1', 'main'),
userText('second', 'main'), assistant('m2', 'main'),
]
const turns = groupIntoTurns(entries, new Set())
expect(turns.map(t => t.gitBranch)).toEqual(['main', 'main'])
const cached = parsedTurnsToCachedTurns(turns)
// First turn stores 'main'; second inherits (no stored branch).
expect(cached[0]!.gitBranch).toBe('main')
expect(cached[1]!.gitBranch).toBeUndefined()
})
it('re-stores the branch at a mid-session switch', () => {
const entries = [
userText('a', 'main'), assistant('m1', 'main'),
userText('b', 'feature/x'), assistant('m2', 'feature/x'),
userText('c', 'feature/x'), assistant('m3', 'feature/x'),
userText('d', 'main'), assistant('m4', 'main'),
]
const turns = groupIntoTurns(entries, new Set())
expect(turns.map(t => t.gitBranch)).toEqual(['main', 'feature/x', 'feature/x', 'main'])
const cached = parsedTurnsToCachedTurns(turns)
expect(cached.map(t => t.gitBranch)).toEqual(['main', 'feature/x', undefined, 'main'])
})
})
// ── session meta: ai-title last-wins, pr-link accumulation ─────────────
describe('collectSessionMeta', () => {
it('keeps the LAST ai-title and accumulates unique pr-link URLs', () => {
const meta = emptySessionMeta()
collectSessionMeta({ type: 'ai-title', aiTitle: 'first title' } as JournalEntry, meta)
collectSessionMeta({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/1' } as JournalEntry, meta)
collectSessionMeta({ type: 'ai-title', aiTitle: 'final title' } as JournalEntry, meta)
collectSessionMeta({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/1' } as JournalEntry, meta)
collectSessionMeta({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/2' } as JournalEntry, meta)
collectSessionMeta({ type: 'user', isSidechain: true } as JournalEntry, meta)
expect(meta.title).toBe('final title')
expect(meta.prLinks).toEqual([
'https://github.com/o/r/pull/1',
'https://github.com/o/r/pull/2',
])
expect(meta.isSidechain).toBe(true)
})
it('leaves fields empty for a session with no meta entries', () => {
const meta = emptySessionMeta()
collectSessionMeta({ type: 'user', message: { role: 'user', content: 'hi' } } as JournalEntry, meta)
expect(meta.title).toBeUndefined()
expect(meta.prLinks).toEqual([])
expect(meta.isSidechain).toBe(false)
})
})

View file

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { rm, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
CACHE_VERSION,
type CachedCall,
type CachedFile,
type SessionCache,
emptyCache,
loadCache,
saveCache,
sessionCachePath,
} from '../src/session-cache.js'
const TMP_DIR = join(tmpdir(), `codeburn-rich-cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
beforeEach(() => { process.env['CODEBURN_CACHE_DIR'] = TMP_DIR })
afterEach(async () => { if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true }) })
function richCall(): CachedCall {
return {
provider: 'claude',
model: 'claude-sonnet-4-20250514',
usage: {
inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-07-01T10:00:00Z',
tools: ['Edit'],
bashCommands: [],
skills: [],
subagentTypes: [],
deduplicationKey: 'm1',
locAdded: 12,
locRemoved: 4,
interrupted: true,
userModified: true,
toolErrors: 2,
editFailed: 1,
}
}
function richFile(): CachedFile {
return {
fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
mcpInventory: [],
title: 'A rich session',
prLinks: ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'],
isSidechain: true,
turns: [
{ timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', gitBranch: 'feature/x', calls: [richCall()] },
],
}
}
describe('session cache round-trip for rich-capture fields', () => {
it('preserves per-call, per-turn, and per-session fields through save+load', async () => {
const cache: SessionCache = {
...emptyCache(),
providers: { claude: { envFingerprint: 'fp', files: { '/x/s1.jsonl': richFile() } } },
}
await saveCache(cache)
const loaded = await loadCache()
const file = loaded.providers['claude']!.files['/x/s1.jsonl']!
expect(file.title).toBe('A rich session')
expect(file.prLinks).toEqual(['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'])
expect(file.isSidechain).toBe(true)
const turn = file.turns[0]!
expect(turn.gitBranch).toBe('feature/x')
const call = turn.calls[0]!
expect(call.locAdded).toBe(12)
expect(call.locRemoved).toBe(4)
expect(call.interrupted).toBe(true)
expect(call.userModified).toBe(true)
expect(call.toolErrors).toBe(2)
expect(call.editFailed).toBe(1)
})
it('still loads an old cache written without any rich-capture fields', async () => {
const oldCall = { ...richCall() }
for (const k of ['locAdded', 'locRemoved', 'interrupted', 'userModified', 'toolErrors', 'editFailed'] as const) {
delete (oldCall as Record<string, unknown>)[k]
}
const oldCache: SessionCache = {
version: CACHE_VERSION,
complete: true,
providers: {
claude: {
envFingerprint: 'fp',
files: {
'/x/old.jsonl': {
fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
mcpInventory: [],
turns: [{ timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', calls: [oldCall] }],
},
},
},
},
}
if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true })
await writeFile(sessionCachePath(), JSON.stringify(oldCache), 'utf-8')
const loaded = await loadCache()
const call = loaded.providers['claude']!.files['/x/old.jsonl']!.turns[0]!.calls[0]!
expect(call.deduplicationKey).toBe('m1')
expect(call.locAdded).toBeUndefined()
expect(call.editFailed).toBeUndefined()
expect(loaded.providers['claude']!.files['/x/old.jsonl']!.title).toBeUndefined()
})
})