diff --git a/src/codex-cache.ts b/src/codex-cache.ts index b2499c8c..56b34ca5 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -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 } diff --git a/src/parser.ts b/src/parser.ts index 56be7eaa..bd6aaf68 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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)['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)['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): 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)['toolUseResult'] + const turObj = tur && typeof tur === 'object' ? tur as Record : 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)['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)['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): 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): ParsedTurn[] { +export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, toolResultMeta?: Map): 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): 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): 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() + 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() + 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; sessionMeta?: SessionMeta }, ): Promise { 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 diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 95300bf9..9fd5e357 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -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): 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): SessionPars pendingTools.push('Edit') const p = entry.payload as Record const changes = p['changes'] - const filePaths = typeof changes === 'object' && changes ? Object.keys(changes as object) : [] + const changesObj = typeof changes === 'object' && changes ? changes as Record : {} + 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 | 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): 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): 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): 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 } } diff --git a/src/providers/types.ts b/src/providers/types.ts index d6363d10..75c277aa 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -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 diff --git a/src/session-cache.ts b/src/session-cache.ts index 95e3f869..c876b31c 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -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 = 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 = { - 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) } diff --git a/src/types.ts b/src/types.ts index 220fb290..6052a07f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { diff --git a/tests/codex-rich-capture.test.ts b/tests/codex-rich-capture.test.ts new file mode 100644 index 00000000..3f368241 --- /dev/null +++ b/tests/codex-rich-capture.test.ts @@ -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 { + 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 { + 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; 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() + }) +}) diff --git a/tests/parser-rich-capture.test.ts b/tests/parser-rich-capture.test.ts new file mode 100644 index 00000000..2eaa23cf --- /dev/null +++ b/tests/parser-rich-capture.test.ts @@ -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() + 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() + 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() + 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) + }) +}) diff --git a/tests/session-cache-rich-capture.test.ts b/tests/session-cache-rich-capture.test.ts new file mode 100644 index 00000000..7848c777 --- /dev/null +++ b/tests/session-cache-rich-capture.test.ts @@ -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)[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() + }) +})