codevigil 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codevigil/parser.py ADDED
@@ -0,0 +1,673 @@
1
+ """Streaming JSONL parser for Claude Code session files.
2
+
3
+ Public API: ``SessionParser``. Construct one per session file, feed an
4
+ iterable of raw lines through ``parse(lines)``, and consume the yielded
5
+ ``Event`` objects lazily. After iteration completes the ``stats`` property
6
+ exposes the per-session :class:`ParseStats` snapshot the
7
+ ``ParseHealthCollector`` reads to compute drift severity.
8
+
9
+ Design choices
10
+
11
+ * The parser is implemented as a class rather than a free function so the
12
+ per-session bookkeeping (parse_confidence counters, schema fingerprint
13
+ sampler de-dup, unknown-tool de-dup) has an obvious home and the
14
+ collector wiring is a constructor argument instead of module-global
15
+ state.
16
+ * ``parse(lines)`` is a generator: memory is O(1) in the number of input
17
+ lines, verified by ``tests/test_parser_streaming.py``.
18
+ * Drift signals reach the ``ParseHealthCollector`` via the shared
19
+ :class:`ParseStats` instance, not via sentinel values stuffed into event
20
+ payloads. The collector receives the same instance through its
21
+ constructor and reads ``parse_confidence`` on every snapshot.
22
+ * ``safe_get`` is intentionally NOT used for the per-line drift counters:
23
+ it routes drift through the error channel which the collector cannot
24
+ observe directly. Instead the parser maintains its own counter on
25
+ :class:`ParseStats` for every required field it pulls.
26
+
27
+ The Event payload schemas produced by this parser are the authoritative
28
+ implementation of the table in ``docs/design.md`` §Payload Schemas by
29
+ EventKind. The schema is frozen after this PR — additive changes only.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import json
36
+ from collections.abc import Iterable, Iterator
37
+ from dataclasses import dataclass, field
38
+ from datetime import UTC, datetime
39
+ from typing import Any
40
+
41
+ from codevigil.errors import (
42
+ CodevigilError,
43
+ ErrorLevel,
44
+ ErrorSource,
45
+ record,
46
+ )
47
+ from codevigil.types import Event, EventKind
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Tool name canonicalisation
51
+ # ---------------------------------------------------------------------------
52
+
53
+ #: Canonicalisation table from raw Claude Code tool names to the lowercase
54
+ #: snake_case identifiers the rest of the pipeline uses. Unknown raw names are
55
+ #: passed through verbatim and trigger a one-time INFO via the error channel.
56
+ TOOL_ALIASES: dict[str, str] = {
57
+ "Bash": "bash",
58
+ "bash_tool": "bash",
59
+ "BashTool": "bash",
60
+ "Read": "read",
61
+ "View": "read",
62
+ "ReadFile": "read",
63
+ "Edit": "edit",
64
+ "EditFile": "edit",
65
+ "MultiEdit": "multi_edit",
66
+ "Write": "write",
67
+ "WriteFile": "write",
68
+ "Glob": "glob",
69
+ "Grep": "grep",
70
+ "GrepTool": "grep",
71
+ "LS": "ls",
72
+ "ListDirectory": "ls",
73
+ "WebFetch": "web_fetch",
74
+ "WebSearch": "web_search",
75
+ "TodoWrite": "todo_write",
76
+ "Task": "task",
77
+ "NotebookEdit": "notebook_edit",
78
+ }
79
+
80
+
81
+ def canonicalise_tool_name(raw: str) -> str:
82
+ """Return the canonical snake_case form of a tool name.
83
+
84
+ Unknown names fall through unchanged. Callers wanting the
85
+ "unknown tool, log once" behaviour use :class:`SessionParser` which
86
+ consults this table and de-duplicates the warnings per parse run.
87
+ """
88
+
89
+ return TOOL_ALIASES.get(raw, raw)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Schema fingerprinting
94
+ # ---------------------------------------------------------------------------
95
+
96
+ #: Known schema epochs keyed by fingerprint hash. Seeded with the shape of the
97
+ #: synthetic happy-path session used by the tests so the v0.1 happy path stays
98
+ #: silent. New entries are committed as Claude Code's wire format evolves.
99
+ KNOWN_FINGERPRINTS: dict[str, str] = {}
100
+
101
+
102
+ _Fingerprint = tuple[tuple[str, ...], tuple[tuple[str, str], ...]]
103
+
104
+
105
+ def _line_fingerprint(parsed: dict[str, Any]) -> _Fingerprint:
106
+ """Return the structural fingerprint tuple for one parsed JSON line."""
107
+
108
+ keys = tuple(sorted(parsed.keys()))
109
+ typed = tuple(sorted((k, type(v).__name__) for k, v in parsed.items()))
110
+ return keys, typed
111
+
112
+
113
+ def _fingerprint_hash(fp: _Fingerprint) -> str:
114
+ payload = json.dumps(fp, sort_keys=True, separators=(",", ":")).encode("utf-8")
115
+ return hashlib.sha256(payload).hexdigest()[:16]
116
+
117
+
118
+ # Seed KNOWN_FINGERPRINTS with realistic shapes. These mirror the synthetic
119
+ # happy-path fixture used in tests/test_parser_happy_path.py and the modern
120
+ # Claude Code session format observed in the wild.
121
+ def _seed_known_fingerprints() -> None:
122
+ samples: list[tuple[dict[str, Any], str]] = [
123
+ (
124
+ {"type": "assistant", "timestamp": "", "session_id": "", "message": {}},
125
+ "2026-03-claude-code",
126
+ ),
127
+ (
128
+ {"type": "user", "timestamp": "", "session_id": "", "message": {}},
129
+ "2026-03-claude-code",
130
+ ),
131
+ (
132
+ {"type": "system", "timestamp": "", "session_id": "", "subtype": ""},
133
+ "2026-03-claude-code",
134
+ ),
135
+ ]
136
+ for sample, epoch in samples:
137
+ KNOWN_FINGERPRINTS[_fingerprint_hash(_line_fingerprint(sample))] = epoch
138
+
139
+
140
+ _seed_known_fingerprints()
141
+
142
+ # Number of leading lines from which to sample fingerprints.
143
+ _FINGERPRINT_SAMPLE_SIZE: int = 10
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Parse statistics shared with ParseHealthCollector
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ @dataclass
152
+ class ParseStats:
153
+ """Mutable per-session counters the parser updates on every line.
154
+
155
+ The :class:`ParseHealthCollector` receives the same instance via its
156
+ constructor and reads :attr:`parse_confidence` on every snapshot, which
157
+ is how drift detection bridges from the parser to the collector without
158
+ routing through the global error channel.
159
+ """
160
+
161
+ total_lines: int = 0
162
+ parsed_events: int = 0
163
+ missing_fields: dict[str, int] = field(default_factory=dict)
164
+
165
+ def record_missing(self, field_name: str) -> None:
166
+ self.missing_fields[field_name] = self.missing_fields.get(field_name, 0) + 1
167
+
168
+ @property
169
+ def parse_confidence(self) -> float:
170
+ """Ratio of successfully-parsed events to total lines seen.
171
+
172
+ Returns ``1.0`` for an empty session so a freshly-constructed
173
+ collector reports OK rather than flapping CRITICAL on zero data.
174
+ """
175
+
176
+ if self.total_lines == 0:
177
+ return 1.0
178
+ return min(1.0, self.parsed_events / self.total_lines)
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Parser
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ class SessionParser:
187
+ """Streaming Claude Code session parser.
188
+
189
+ One instance per session file. ``parse(lines)`` is a generator and may
190
+ be consumed once. After iteration, :attr:`stats` holds the per-session
191
+ counters and :attr:`fingerprint_warned` records whether an
192
+ unknown-fingerprint WARN has already been emitted for this run (the
193
+ parser emits exactly one such warning per session).
194
+ """
195
+
196
+ def __init__(self, *, session_id: str = "unknown") -> None:
197
+ self._session_id: str = session_id
198
+ self._stats: ParseStats = ParseStats()
199
+ self._unknown_tools_seen: set[str] = set()
200
+ self._fingerprint_warned: bool = False
201
+ self._lines_fingerprinted: int = 0
202
+
203
+ @property
204
+ def stats(self) -> ParseStats:
205
+ return self._stats
206
+
207
+ @property
208
+ def session_id(self) -> str:
209
+ return self._session_id
210
+
211
+ @property
212
+ def fingerprint_warned(self) -> bool:
213
+ return self._fingerprint_warned
214
+
215
+ def parse(self, lines: Iterable[str]) -> Iterator[Event]:
216
+ """Yield :class:`Event` objects for every parseable line.
217
+
218
+ Malformed JSON, JSON without a ``type`` field, and JSON with an
219
+ unknown ``type`` value are logged via the error channel and
220
+ skipped. The parser never raises on per-line errors.
221
+ """
222
+
223
+ for raw_line in lines:
224
+ line = raw_line.strip()
225
+ if not line:
226
+ continue
227
+ self._stats.total_lines += 1
228
+
229
+ parsed = self._decode_line(line)
230
+ if parsed is None:
231
+ continue
232
+
233
+ self._sample_fingerprint(parsed)
234
+
235
+ kind_field = parsed.get("type")
236
+ if not isinstance(kind_field, str):
237
+ self._stats.record_missing("type")
238
+ record(
239
+ CodevigilError(
240
+ level=ErrorLevel.WARN,
241
+ source=ErrorSource.PARSER,
242
+ code="parser.missing_type",
243
+ message="line missing top-level 'type' field",
244
+ context={"session_id": self._session_id},
245
+ )
246
+ )
247
+ continue
248
+
249
+ yield from self._dispatch(parsed, kind_field)
250
+
251
+ # ------------------------------------------------------------------
252
+ # Line-level helpers
253
+ # ------------------------------------------------------------------
254
+
255
+ def _decode_line(self, line: str) -> dict[str, Any] | None:
256
+ try:
257
+ decoded = json.loads(line)
258
+ except json.JSONDecodeError as exc:
259
+ record(
260
+ CodevigilError(
261
+ level=ErrorLevel.WARN,
262
+ source=ErrorSource.PARSER,
263
+ code="parser.malformed_line",
264
+ message=f"failed to decode JSONL line: {exc.msg}",
265
+ context={
266
+ "session_id": self._session_id,
267
+ "position": exc.pos,
268
+ },
269
+ )
270
+ )
271
+ self._stats.record_missing("__json__")
272
+ return None
273
+ if not isinstance(decoded, dict):
274
+ record(
275
+ CodevigilError(
276
+ level=ErrorLevel.WARN,
277
+ source=ErrorSource.PARSER,
278
+ code="parser.malformed_line",
279
+ message=(
280
+ f"top-level JSONL value is not an object; got {type(decoded).__name__}"
281
+ ),
282
+ context={"session_id": self._session_id},
283
+ )
284
+ )
285
+ self._stats.record_missing("__object__")
286
+ return None
287
+ return decoded
288
+
289
+ def _sample_fingerprint(self, parsed: dict[str, Any]) -> None:
290
+ if self._lines_fingerprinted >= _FINGERPRINT_SAMPLE_SIZE:
291
+ return
292
+ self._lines_fingerprinted += 1
293
+ fp = _line_fingerprint(parsed)
294
+ digest = _fingerprint_hash(fp)
295
+ if digest in KNOWN_FINGERPRINTS:
296
+ return
297
+ if self._fingerprint_warned:
298
+ return
299
+ self._fingerprint_warned = True
300
+ record(
301
+ CodevigilError(
302
+ level=ErrorLevel.WARN,
303
+ source=ErrorSource.PARSER,
304
+ code="parser.unknown_fingerprint",
305
+ message=(
306
+ "observed JSONL line shape not in KNOWN_FINGERPRINTS; "
307
+ "Claude Code session schema may have changed"
308
+ ),
309
+ context={
310
+ "session_id": self._session_id,
311
+ "fingerprint": digest,
312
+ "keys": list(fp[0]),
313
+ },
314
+ )
315
+ )
316
+
317
+ # ------------------------------------------------------------------
318
+ # Dispatch and per-kind extraction
319
+ # ------------------------------------------------------------------
320
+
321
+ def _dispatch(self, parsed: dict[str, Any], kind_field: str) -> Iterator[Event]:
322
+ timestamp = self._extract_timestamp(parsed)
323
+ session_id = self._extract_session_id(parsed)
324
+
325
+ if kind_field == "assistant":
326
+ yield from self._emit_assistant(parsed, timestamp, session_id)
327
+ elif kind_field == "user":
328
+ yield from self._emit_user(parsed, timestamp, session_id)
329
+ elif kind_field == "system":
330
+ yield from self._emit_system(parsed, timestamp, session_id)
331
+ else:
332
+ record(
333
+ CodevigilError(
334
+ level=ErrorLevel.WARN,
335
+ source=ErrorSource.PARSER,
336
+ code="parser.unknown_type",
337
+ message=f"unknown top-level type {kind_field!r}",
338
+ context={
339
+ "session_id": self._session_id,
340
+ "type": kind_field,
341
+ },
342
+ )
343
+ )
344
+ self._stats.record_missing("type")
345
+
346
+ def _extract_timestamp(self, parsed: dict[str, Any]) -> datetime:
347
+ raw = parsed.get("timestamp")
348
+ if isinstance(raw, str) and raw:
349
+ try:
350
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
351
+ except ValueError:
352
+ self._stats.record_missing("timestamp")
353
+ elif raw is None:
354
+ self._stats.record_missing("timestamp")
355
+ return datetime.now(tz=UTC)
356
+
357
+ def _extract_session_id(self, parsed: dict[str, Any]) -> str:
358
+ raw = parsed.get("session_id")
359
+ if isinstance(raw, str) and raw:
360
+ return raw
361
+ return self._session_id
362
+
363
+ def _content_blocks(self, message: dict[str, Any]) -> list[dict[str, Any]]:
364
+ content = message.get("content")
365
+ if isinstance(content, list):
366
+ return [block for block in content if isinstance(block, dict)]
367
+ if isinstance(content, str):
368
+ return [{"type": "text", "text": content}]
369
+ return []
370
+
371
+ def _emit_assistant(
372
+ self,
373
+ parsed: dict[str, Any],
374
+ timestamp: datetime,
375
+ session_id: str,
376
+ ) -> Iterator[Event]:
377
+ message = parsed.get("message")
378
+ if not isinstance(message, dict):
379
+ self._stats.record_missing("message")
380
+ record(
381
+ CodevigilError(
382
+ level=ErrorLevel.WARN,
383
+ source=ErrorSource.PARSER,
384
+ code="parser.missing_message",
385
+ message="assistant line missing 'message' object",
386
+ context={"session_id": session_id},
387
+ )
388
+ )
389
+ return
390
+
391
+ emitted = 0
392
+ for block in self._content_blocks(message):
393
+ block_type = block.get("type")
394
+ if block_type == "text":
395
+ event = self._build_assistant_text_event(block, message, timestamp, session_id)
396
+ if event is not None:
397
+ emitted += 1
398
+ yield event
399
+ elif block_type == "tool_use":
400
+ event = self._build_tool_call_event(block, timestamp, session_id)
401
+ if event is not None:
402
+ emitted += 1
403
+ yield event
404
+ elif block_type == "thinking":
405
+ event = self._build_thinking_event(block, timestamp, session_id)
406
+ if event is not None:
407
+ emitted += 1
408
+ yield event
409
+ else:
410
+ self._stats.record_missing("content.type")
411
+
412
+ if emitted == 0:
413
+ # Nothing useful in the line: still count it as parsed so a
414
+ # lone "assistant with no content" doesn't masquerade as drift.
415
+ self._stats.parsed_events += 1
416
+ else:
417
+ self._stats.parsed_events += emitted
418
+
419
+ def _build_assistant_text_event(
420
+ self,
421
+ block: dict[str, Any],
422
+ message: dict[str, Any],
423
+ timestamp: datetime,
424
+ session_id: str,
425
+ ) -> Event | None:
426
+ text = block.get("text")
427
+ if not isinstance(text, str):
428
+ self._stats.record_missing("text")
429
+ return None
430
+ payload: dict[str, Any] = {"text": text}
431
+ token_count = self._extract_token_count(message)
432
+ if token_count is not None:
433
+ payload["token_count"] = token_count
434
+ return Event(
435
+ timestamp=timestamp,
436
+ session_id=session_id,
437
+ kind=EventKind.ASSISTANT_MESSAGE,
438
+ payload=payload,
439
+ )
440
+
441
+ def _extract_token_count(self, message: dict[str, Any]) -> int | None:
442
+ usage = message.get("usage")
443
+ if not isinstance(usage, dict):
444
+ return None
445
+ out = usage.get("output_tokens")
446
+ if isinstance(out, int):
447
+ return out
448
+ return None
449
+
450
+ def _build_tool_call_event(
451
+ self,
452
+ block: dict[str, Any],
453
+ timestamp: datetime,
454
+ session_id: str,
455
+ ) -> Event | None:
456
+ raw_name = block.get("name")
457
+ tool_use_id = block.get("id")
458
+ tool_input = block.get("input")
459
+ if not isinstance(raw_name, str):
460
+ self._stats.record_missing("tool_name")
461
+ return None
462
+ if not isinstance(tool_use_id, str):
463
+ self._stats.record_missing("tool_use_id")
464
+ return None
465
+ if not isinstance(tool_input, dict):
466
+ self._stats.record_missing("input")
467
+ tool_input = {}
468
+ canonical = canonicalise_tool_name(raw_name)
469
+ if canonical == raw_name and raw_name not in TOOL_ALIASES.values():
470
+ self._note_unknown_tool(raw_name)
471
+ payload: dict[str, Any] = {
472
+ "tool_name": canonical,
473
+ "tool_use_id": tool_use_id,
474
+ "input": dict(tool_input),
475
+ }
476
+ file_path = tool_input.get("file_path")
477
+ if isinstance(file_path, str):
478
+ payload["file_path"] = file_path
479
+ return Event(
480
+ timestamp=timestamp,
481
+ session_id=session_id,
482
+ kind=EventKind.TOOL_CALL,
483
+ payload=payload,
484
+ )
485
+
486
+ def _note_unknown_tool(self, raw_name: str) -> None:
487
+ if raw_name in self._unknown_tools_seen:
488
+ return
489
+ self._unknown_tools_seen.add(raw_name)
490
+ record(
491
+ CodevigilError(
492
+ level=ErrorLevel.INFO,
493
+ source=ErrorSource.PARSER,
494
+ code="parser.unknown_tool",
495
+ message=f"unrecognised tool name {raw_name!r}; passing through verbatim",
496
+ context={
497
+ "session_id": self._session_id,
498
+ "tool_name": raw_name,
499
+ },
500
+ )
501
+ )
502
+
503
+ def _build_thinking_event(
504
+ self,
505
+ block: dict[str, Any],
506
+ timestamp: datetime,
507
+ session_id: str,
508
+ ) -> Event | None:
509
+ raw_text = block.get("thinking")
510
+ signature = block.get("signature")
511
+ payload: dict[str, Any]
512
+ if raw_text == "[redacted]" or block.get("redacted") is True:
513
+ payload = {
514
+ "length": 0,
515
+ "redacted": True,
516
+ "text": "",
517
+ }
518
+ elif isinstance(raw_text, str):
519
+ payload = {
520
+ "length": len(raw_text),
521
+ "redacted": False,
522
+ "text": raw_text,
523
+ }
524
+ else:
525
+ self._stats.record_missing("thinking")
526
+ return None
527
+ if isinstance(signature, str):
528
+ payload["signature"] = signature
529
+ return Event(
530
+ timestamp=timestamp,
531
+ session_id=session_id,
532
+ kind=EventKind.THINKING,
533
+ payload=payload,
534
+ )
535
+
536
+ def _emit_user(
537
+ self,
538
+ parsed: dict[str, Any],
539
+ timestamp: datetime,
540
+ session_id: str,
541
+ ) -> Iterator[Event]:
542
+ message = parsed.get("message")
543
+ if not isinstance(message, dict):
544
+ self._stats.record_missing("message")
545
+ record(
546
+ CodevigilError(
547
+ level=ErrorLevel.WARN,
548
+ source=ErrorSource.PARSER,
549
+ code="parser.missing_message",
550
+ message="user line missing 'message' object",
551
+ context={"session_id": session_id},
552
+ )
553
+ )
554
+ return
555
+
556
+ blocks = self._content_blocks(message)
557
+ emitted = 0
558
+ if not blocks:
559
+ text = message.get("content") if isinstance(message.get("content"), str) else None
560
+ if isinstance(text, str):
561
+ yield Event(
562
+ timestamp=timestamp,
563
+ session_id=session_id,
564
+ kind=EventKind.USER_MESSAGE,
565
+ payload={"text": text},
566
+ )
567
+ emitted += 1
568
+ else:
569
+ self._stats.record_missing("text")
570
+
571
+ for block in blocks:
572
+ block_type = block.get("type")
573
+ if block_type == "text":
574
+ text = block.get("text")
575
+ if not isinstance(text, str):
576
+ self._stats.record_missing("text")
577
+ continue
578
+ yield Event(
579
+ timestamp=timestamp,
580
+ session_id=session_id,
581
+ kind=EventKind.USER_MESSAGE,
582
+ payload={"text": text},
583
+ )
584
+ emitted += 1
585
+ elif block_type == "tool_result":
586
+ event = self._build_tool_result_event(block, timestamp, session_id)
587
+ if event is not None:
588
+ yield event
589
+ emitted += 1
590
+ else:
591
+ self._stats.record_missing("content.type")
592
+
593
+ if emitted == 0:
594
+ self._stats.parsed_events += 1
595
+ else:
596
+ self._stats.parsed_events += emitted
597
+
598
+ def _build_tool_result_event(
599
+ self,
600
+ block: dict[str, Any],
601
+ timestamp: datetime,
602
+ session_id: str,
603
+ ) -> Event | None:
604
+ tool_use_id = block.get("tool_use_id")
605
+ if not isinstance(tool_use_id, str):
606
+ self._stats.record_missing("tool_use_id")
607
+ return None
608
+ is_error = bool(block.get("is_error", False))
609
+ payload: dict[str, Any] = {
610
+ "tool_use_id": tool_use_id,
611
+ "is_error": is_error,
612
+ }
613
+ content = block.get("content")
614
+ if isinstance(content, str):
615
+ payload["output"] = content
616
+ elif isinstance(content, list):
617
+ parts: list[str] = []
618
+ for item in content:
619
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
620
+ parts.append(item["text"])
621
+ payload["output"] = "\n".join(parts)
622
+ if "truncated" in block:
623
+ payload["truncated"] = bool(block["truncated"])
624
+ return Event(
625
+ timestamp=timestamp,
626
+ session_id=session_id,
627
+ kind=EventKind.TOOL_RESULT,
628
+ payload=payload,
629
+ )
630
+
631
+ def _emit_system(
632
+ self,
633
+ parsed: dict[str, Any],
634
+ timestamp: datetime,
635
+ session_id: str,
636
+ ) -> Iterator[Event]:
637
+ subkind_raw = parsed.get("subtype") or parsed.get("subkind") or "unknown"
638
+ subkind = subkind_raw if isinstance(subkind_raw, str) else "unknown"
639
+ payload: dict[str, Any] = {"subkind": subkind}
640
+ for key, value in parsed.items():
641
+ if key in {"type", "timestamp", "session_id", "subtype", "subkind"}:
642
+ continue
643
+ payload[key] = value
644
+ self._stats.parsed_events += 1
645
+ yield Event(
646
+ timestamp=timestamp,
647
+ session_id=session_id,
648
+ kind=EventKind.SYSTEM,
649
+ payload=payload,
650
+ )
651
+
652
+
653
+ def parse_session(lines: Iterable[str], *, session_id: str = "unknown") -> Iterator[Event]:
654
+ """Convenience function for callers that don't need the stats handle.
655
+
656
+ Wraps :class:`SessionParser` so the simple "iterate events" use case
657
+ stays a one-liner. Callers that want :class:`ParseStats` (notably the
658
+ aggregator wiring up :class:`ParseHealthCollector`) construct the
659
+ parser explicitly instead.
660
+ """
661
+
662
+ parser = SessionParser(session_id=session_id)
663
+ return parser.parse(lines)
664
+
665
+
666
+ __all__ = [
667
+ "KNOWN_FINGERPRINTS",
668
+ "TOOL_ALIASES",
669
+ "ParseStats",
670
+ "SessionParser",
671
+ "canonicalise_tool_name",
672
+ "parse_session",
673
+ ]