( filePath: string, project: string, seenMsgIds: Set<string>, dateRange?: DateRange, )
| 1404 | } |
| 1405 | |
| 1406 | async function parseSessionFile( |
| 1407 | filePath: string, |
| 1408 | project: string, |
| 1409 | seenMsgIds: Set<string>, |
| 1410 | dateRange?: DateRange, |
| 1411 | ): Promise<{ session: SessionSummary; canonicalCwd?: string } | null> { |
| 1412 | // Skip files whose mtime is older than the range start. A session file |
| 1413 | // can only contain entries up to its last-modified time; if that predates |
| 1414 | // the requested range, nothing in this file can match. |
| 1415 | if (dateRange) { |
| 1416 | try { |
| 1417 | const s = await stat(filePath) |
| 1418 | if (s.mtimeMs < dateRange.start.getTime()) return null |
| 1419 | } catch { /* fall through to normal read; missing stat shouldn't break parsing */ } |
| 1420 | } |
| 1421 | const entries: JournalEntry[] = [] |
| 1422 | let hasLines = false |
| 1423 | |
| 1424 | // When a dateRange is given, skip user/assistant lines whose timestamp |
| 1425 | // is older than range.start - 24h without calling JSON.parse. Huge lines |
| 1426 | // that cannot be skipped are yielded as Buffers and compact-parsed without |
| 1427 | // converting the whole line into a V8 string. |
| 1428 | const earlySkipThreshold = dateRange |
| 1429 | ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() |
| 1430 | : null |
| 1431 | const skipFn = earlySkipThreshold |
| 1432 | ? (head: string) => shouldSkipLine(head, earlySkipThreshold) |
| 1433 | : undefined |
| 1434 | |
| 1435 | for await (const line of readSessionLines(filePath, skipFn, { largeLineAsBuffer: true })) { |
| 1436 | hasLines = true |
| 1437 | const entry = parseJsonlLine(line) |
| 1438 | if (entry) entries.push(compactEntry(entry)) |
| 1439 | } |
| 1440 | |
| 1441 | if (!hasLines) return null |
| 1442 | |
| 1443 | if (entries.length === 0) return null |
| 1444 | |
| 1445 | const sessionId = basename(filePath, '.jsonl') |
| 1446 | const dedupedEntries = dedupeStreamingMessageIds(entries) |
| 1447 | let turns = groupIntoTurns(dedupedEntries, seenMsgIds) |
| 1448 | if (dateRange) { |
| 1449 | // Bucket a turn by the timestamp of its first assistant call (when the cost was |
| 1450 | // actually incurred). Filtering entries directly produced orphan assistant calls |
| 1451 | // when a user message sat in one day and the response landed in another -- those |
| 1452 | // got pushed as turns with empty timestamps, which some code paths counted and |
| 1453 | // others dropped, producing inconsistent Today totals. |
| 1454 | turns = turns.filter(turn => { |
| 1455 | if (turn.assistantCalls.length === 0) return false |
| 1456 | const firstCallTs = turn.assistantCalls[0]!.timestamp |
| 1457 | if (!firstCallTs) return false |
| 1458 | const ts = new Date(firstCallTs) |
| 1459 | return ts >= dateRange.start && ts <= dateRange.end |
| 1460 | }) |
| 1461 | if (turns.length === 0) return null |
| 1462 | } |
| 1463 | const classified = turns.map(classifyTurn) |
nothing calls this directly
no test coverage detected