Parse a JSONL file and return (session_metas, turns, agents, line_count). Deduplicates streaming events by message.id — Claude Code logs multiple JSONL records per API response, all sharing the same message.id. Only the last record per message_id is kept (it has the final usage tallies)
(filepath)
| 315 | |
| 316 | |
| 317 | def parse_jsonl_file(filepath): |
| 318 | """Parse a JSONL file and return (session_metas, turns, agents, line_count). |
| 319 | |
| 320 | Deduplicates streaming events by message.id — Claude Code logs multiple |
| 321 | JSONL records per API response, all sharing the same message.id. Only the |
| 322 | last record per message_id is kept (it has the final usage tallies). |
| 323 | """ |
| 324 | seen_messages = {} # message_id -> turn dict (dedup streaming records) |
| 325 | turns_no_id = [] # turns without a message_id (kept as-is) |
| 326 | session_meta = {} # session_id -> dict |
| 327 | agents = {} # agent_id -> dispatch dict |
| 328 | line_count = 0 |
| 329 | |
| 330 | try: |
| 331 | with open(filepath, encoding="utf-8", errors="replace") as f: |
| 332 | for line_count, line in enumerate(f, 1): |
| 333 | line = line.strip() |
| 334 | if not line: |
| 335 | continue |
| 336 | try: |
| 337 | record = json.loads(line) |
| 338 | except json.JSONDecodeError: |
| 339 | continue |
| 340 | |
| 341 | rtype = record.get("type") |
| 342 | if rtype not in ("assistant", "user", "custom-title", "ai-title"): |
| 343 | continue |
| 344 | |
| 345 | session_id = record.get("sessionId") |
| 346 | if not session_id: |
| 347 | continue |
| 348 | |
| 349 | # Extract session title from title records |
| 350 | title = _extract_title(record) |
| 351 | if title: |
| 352 | if session_id not in session_meta: |
| 353 | session_meta[session_id] = { |
| 354 | "session_id": session_id, |
| 355 | "project_name": "unknown", |
| 356 | "first_timestamp": "", |
| 357 | "last_timestamp": "", |
| 358 | "git_branch": "", |
| 359 | "model": None, |
| 360 | "topic": None, |
| 361 | } |
| 362 | meta = session_meta[session_id] |
| 363 | # custom-title always wins; ai-title only if no custom-title set |
| 364 | if rtype == "custom-title": |
| 365 | meta["topic"] = title |
| 366 | elif rtype == "ai-title" and not meta.get("topic"): |
| 367 | meta["topic"] = title |
| 368 | continue |
| 369 | |
| 370 | if rtype == "user": |
| 371 | dispatch = extract_agent_dispatch(record) |
| 372 | if dispatch is not None: |
| 373 | agents[dispatch["agent_id"]] = dispatch |
| 374 |