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/__init__.py +19 -0
- codevigil/__main__.py +10 -0
- codevigil/aggregator.py +506 -0
- codevigil/bootstrap.py +284 -0
- codevigil/cli.py +732 -0
- codevigil/collectors/__init__.py +24 -0
- codevigil/collectors/_text_match.py +271 -0
- codevigil/collectors/parse_health.py +94 -0
- codevigil/collectors/read_edit_ratio.py +258 -0
- codevigil/collectors/reasoning_loop.py +167 -0
- codevigil/collectors/stop_phrase.py +266 -0
- codevigil/config.py +776 -0
- codevigil/errors.py +211 -0
- codevigil/parser.py +673 -0
- codevigil/privacy.py +191 -0
- codevigil/projects.py +132 -0
- codevigil/registry.py +121 -0
- codevigil/renderers/__init__.py +20 -0
- codevigil/renderers/json_file.py +105 -0
- codevigil/renderers/terminal.py +236 -0
- codevigil/types.py +189 -0
- codevigil/watcher.py +456 -0
- codevigil-0.1.0.dist-info/METADATA +351 -0
- codevigil-0.1.0.dist-info/RECORD +27 -0
- codevigil-0.1.0.dist-info/WHEEL +4 -0
- codevigil-0.1.0.dist-info/entry_points.txt +2 -0
- codevigil-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
]
|