execweave 0.6.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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,604 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from copy import deepcopy
8
+ from dataclasses import asdict, dataclass, field
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .schema import SCHEMA_VERSION
14
+ from .validate import validate_event_stream
15
+
16
+ _RELATION = "CORRELATED_WITH_PROCESS"
17
+ _EVENT_TYPE = "inference.tool_process.correlation"
18
+ _BUILTINS = {
19
+ ".",
20
+ "[",
21
+ "alias",
22
+ "bg",
23
+ "break",
24
+ "cd",
25
+ "command",
26
+ "continue",
27
+ "echo",
28
+ "eval",
29
+ "exec",
30
+ "exit",
31
+ "export",
32
+ "false",
33
+ "fg",
34
+ "hash",
35
+ "jobs",
36
+ "printf",
37
+ "pwd",
38
+ "read",
39
+ "return",
40
+ "set",
41
+ "shift",
42
+ "source",
43
+ "test",
44
+ "trap",
45
+ "true",
46
+ "type",
47
+ "ulimit",
48
+ "umask",
49
+ "unalias",
50
+ "unset",
51
+ "wait",
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CorrelationResult:
57
+ session_id: str
58
+ input_event_count: int
59
+ output_event_count: int
60
+ tool_calls_considered: int
61
+ correlated_tool_calls: int
62
+ skipped_unsupported: int
63
+ skipped_no_match: int
64
+ skipped_ambiguous: int
65
+ max_window_ms: int
66
+ output: str
67
+
68
+ def to_dict(self) -> dict[str, object]:
69
+ return asdict(self)
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class _Declaration:
74
+ event: dict[str, Any]
75
+ timestamp: datetime
76
+ tool_call: dict[str, Any]
77
+ command_entity: dict[str, Any]
78
+ command_token: str
79
+ command_head: str
80
+ command_argv: tuple[str, ...]
81
+
82
+
83
+ @dataclass
84
+ class _Candidate:
85
+ process: dict[str, Any]
86
+ latest_timestamp: datetime
87
+ latest_timestamp_text: str
88
+ supporting_event_ids: set[str] = field(default_factory=set)
89
+ has_exec_match: bool = False
90
+ has_process_exe_match: bool = False
91
+ has_cmdline_match: bool = False
92
+ has_argv_tail_match: bool = False
93
+
94
+ def observe(
95
+ self,
96
+ event: dict[str, Any],
97
+ *,
98
+ timestamp: datetime,
99
+ exec_match: bool = False,
100
+ process_exe_match: bool = False,
101
+ cmdline_match: bool = False,
102
+ argv_tail_match: bool = False,
103
+ ) -> None:
104
+ if timestamp >= self.latest_timestamp:
105
+ self.latest_timestamp = timestamp
106
+ self.latest_timestamp_text = str(event.get("timestamp"))
107
+ event_id = event.get("event_id")
108
+ if isinstance(event_id, str) and event_id:
109
+ self.supporting_event_ids.add(event_id)
110
+ self.has_exec_match = self.has_exec_match or exec_match
111
+ self.has_process_exe_match = self.has_process_exe_match or process_exe_match
112
+ self.has_cmdline_match = self.has_cmdline_match or cmdline_match
113
+ self.has_argv_tail_match = self.has_argv_tail_match or argv_tail_match
114
+
115
+ def method_and_confidence(self) -> tuple[str, float]:
116
+ if self.has_exec_match and self.has_cmdline_match:
117
+ return "unique_exec_and_cmdline_match", 0.95
118
+ if self.has_exec_match:
119
+ return "unique_exec_identity_match", 0.90
120
+ if self.has_process_exe_match and self.has_cmdline_match:
121
+ return "unique_process_exe_and_cmdline_match", 0.90
122
+ if self.has_process_exe_match:
123
+ return "unique_process_exe_match", 0.85
124
+ if self.has_cmdline_match:
125
+ return "unique_process_cmdline_match", 0.80
126
+ if self.has_argv_tail_match:
127
+ return "unique_process_argv_tail_match", 0.80
128
+ raise RuntimeError("correlation candidate has no matching evidence")
129
+
130
+
131
+ def _parse_timestamp(value: object, *, context: str) -> datetime:
132
+ if not isinstance(value, str) or not value:
133
+ raise ValueError(f"{context}: timestamp must be a non-empty ISO-8601 string")
134
+ try:
135
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
136
+ except ValueError as exc:
137
+ raise ValueError(f"{context}: timestamp must be ISO-8601") from exc
138
+ if parsed.tzinfo is None:
139
+ parsed = parsed.replace(tzinfo=timezone.utc)
140
+ return parsed.astimezone(timezone.utc)
141
+
142
+
143
+ def _load_jsonl(path: Path) -> list[dict[str, Any]]:
144
+ events: list[dict[str, Any]] = []
145
+ for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
146
+ if not raw.strip():
147
+ continue
148
+ try:
149
+ payload = json.loads(raw)
150
+ except json.JSONDecodeError as exc:
151
+ raise ValueError(f"line {line_number}: invalid JSON: {exc.msg}") from exc
152
+ if not isinstance(payload, dict):
153
+ raise ValueError(f"line {line_number}: event must be an object")
154
+ events.append(payload)
155
+ return events
156
+
157
+
158
+ def _clean_executable(value: object) -> str | None:
159
+ if not isinstance(value, str) or not value.strip():
160
+ return None
161
+ return value.strip().strip('"').strip("'") or None
162
+
163
+
164
+ def _normalize_executable(value: object) -> str | None:
165
+ text = _clean_executable(value)
166
+ if text is None:
167
+ return None
168
+ name = text.replace("\\", "/").rsplit("/", 1)[-1].lower()
169
+ if name.endswith(".exe"):
170
+ name = name[:-4]
171
+ return name or None
172
+
173
+
174
+ def _canonical_executable_path(value: object) -> str | None:
175
+ text = _clean_executable(value)
176
+ if text is None:
177
+ return None
178
+ if "/" not in text and "\\" not in text and not Path(text).is_absolute():
179
+ return None
180
+ try:
181
+ resolved = Path(text).expanduser().resolve(strict=False)
182
+ except (OSError, RuntimeError, ValueError):
183
+ return None
184
+ return os.path.normcase(str(resolved))
185
+
186
+
187
+ def _same_executable_identity(candidate: object, declaration: _Declaration) -> bool:
188
+ if _normalize_executable(candidate) == declaration.command_head:
189
+ return True
190
+ declared_path = _canonical_executable_path(declaration.command_token)
191
+ candidate_path = _canonical_executable_path(candidate)
192
+ return declared_path is not None and candidate_path is not None and declared_path == candidate_path
193
+
194
+
195
+ def _has_shell_control(command: str) -> bool:
196
+ quote: str | None = None
197
+ escaped = False
198
+ index = 0
199
+ while index < len(command):
200
+ char = command[index]
201
+ if escaped:
202
+ escaped = False
203
+ index += 1
204
+ continue
205
+ if quote == "'":
206
+ if char == "'":
207
+ quote = None
208
+ index += 1
209
+ continue
210
+ if quote == '"':
211
+ if char == '"':
212
+ quote = None
213
+ elif char == "\\":
214
+ escaped = True
215
+ index += 1
216
+ continue
217
+ if char in {"'", '"'}:
218
+ quote = char
219
+ index += 1
220
+ continue
221
+ if char in "\r\n;&|<>`":
222
+ return True
223
+ if char == "$" and index + 1 < len(command) and command[index + 1] == "(":
224
+ return True
225
+ if char == "\\":
226
+ escaped = True
227
+ index += 1
228
+ return quote is not None
229
+
230
+
231
+ def _command_argv(command: str) -> tuple[str, ...] | None:
232
+ if _has_shell_control(command):
233
+ return None
234
+ tokens: list[str] = []
235
+ chars: list[str] = []
236
+ quote: str | None = None
237
+ token_started = False
238
+ index = 0
239
+ while index < len(command):
240
+ char = command[index]
241
+ if quote is not None:
242
+ if char == quote:
243
+ quote = None
244
+ token_started = True
245
+ elif (
246
+ quote == '"'
247
+ and char == "\\"
248
+ and index + 1 < len(command)
249
+ and command[index + 1] == '"'
250
+ ):
251
+ chars.append('"')
252
+ token_started = True
253
+ index += 1
254
+ else:
255
+ chars.append(char)
256
+ token_started = True
257
+ elif char.isspace():
258
+ if token_started:
259
+ tokens.append("".join(chars))
260
+ chars = []
261
+ token_started = False
262
+ elif char in {"'", '"'}:
263
+ quote = char
264
+ token_started = True
265
+ else:
266
+ chars.append(char)
267
+ token_started = True
268
+ index += 1
269
+ if quote is not None:
270
+ return None
271
+ if token_started:
272
+ tokens.append("".join(chars))
273
+ return tuple(tokens)
274
+
275
+
276
+ def _command_identity(command: str) -> tuple[str, str, tuple[str, ...]] | None:
277
+ argv = _command_argv(command)
278
+ if not argv:
279
+ return None
280
+ token = argv[0]
281
+ if "=" in token:
282
+ return None
283
+ head = _normalize_executable(token)
284
+ if head is None or head in _BUILTINS:
285
+ return None
286
+ return token, head, argv
287
+
288
+
289
+ def _declarations(events: list[dict[str, Any]]) -> list[_Declaration]:
290
+ result: list[_Declaration] = []
291
+ for index, event in enumerate(events, start=1):
292
+ if event.get("relation") != "DECLARED_COMMAND":
293
+ continue
294
+ source = event.get("source")
295
+ target = event.get("target")
296
+ if not isinstance(source, dict) or source.get("type") != "tool_call":
297
+ continue
298
+ if not isinstance(target, dict) or target.get("type") != "command":
299
+ continue
300
+ target_attributes = target.get("attributes") or {}
301
+ command = target_attributes.get("command") if isinstance(target_attributes, dict) else None
302
+ if not isinstance(command, str) or not command:
303
+ continue
304
+ identity = _command_identity(command)
305
+ if identity is None:
306
+ token, head, argv = "", "", ()
307
+ else:
308
+ token, head, argv = identity
309
+ result.append(
310
+ _Declaration(
311
+ event=event,
312
+ timestamp=_parse_timestamp(event.get("timestamp"), context=f"event {index}"),
313
+ tool_call=deepcopy(source),
314
+ command_entity=deepcopy(target),
315
+ command_token=token,
316
+ command_head=head,
317
+ command_argv=argv,
318
+ )
319
+ )
320
+ result.sort(key=lambda item: item.timestamp)
321
+ return result
322
+
323
+
324
+ def _result_times(events: list[dict[str, Any]]) -> dict[str, list[datetime]]:
325
+ result: dict[str, list[datetime]] = {}
326
+ for index, event in enumerate(events, start=1):
327
+ if event.get("relation") not in {"TOOL_CALL_SUCCEEDED", "TOOL_CALL_FAILED"}:
328
+ continue
329
+ source = event.get("source")
330
+ if not isinstance(source, dict):
331
+ continue
332
+ source_id = source.get("id")
333
+ if not isinstance(source_id, str) or not source_id:
334
+ continue
335
+ result.setdefault(source_id, []).append(
336
+ _parse_timestamp(event.get("timestamp"), context=f"event {index}")
337
+ )
338
+ for values in result.values():
339
+ values.sort()
340
+ return result
341
+
342
+
343
+ def _event_time(event: dict[str, Any], *, index: int) -> datetime:
344
+ return _parse_timestamp(event.get("timestamp"), context=f"event {index}")
345
+
346
+
347
+ def _candidate_processes(
348
+ events: list[dict[str, Any]],
349
+ *,
350
+ declaration: _Declaration,
351
+ start: datetime,
352
+ end: datetime,
353
+ ) -> dict[str, _Candidate]:
354
+ candidates: dict[str, _Candidate] = {}
355
+ for index, event in enumerate(events, start=1):
356
+ event_type = event.get("event_type")
357
+ relation = event.get("relation")
358
+ if event_type not in {"process.started", "process.exec"}:
359
+ continue
360
+ timestamp = _event_time(event, index=index)
361
+ if timestamp < start or timestamp > end:
362
+ continue
363
+
364
+ process: dict[str, Any] | None = None
365
+ exec_match = False
366
+ process_exe_match = False
367
+ cmdline_match = False
368
+ argv_tail_match = False
369
+ if event_type == "process.exec" and relation == "EXECUTED":
370
+ source = event.get("source")
371
+ target = event.get("target")
372
+ if not isinstance(source, dict) or source.get("type") != "process":
373
+ continue
374
+ if not isinstance(target, dict) or target.get("type") != "executable":
375
+ continue
376
+ executable_value: object = target.get("name")
377
+ executable_id = target.get("id")
378
+ if isinstance(executable_id, str) and executable_id.startswith("executable:"):
379
+ executable_value = executable_id[len("executable:") :]
380
+ if not _same_executable_identity(executable_value, declaration):
381
+ continue
382
+ process = deepcopy(source)
383
+ exec_match = True
384
+ elif event_type == "process.started":
385
+ target = event.get("target")
386
+ if not isinstance(target, dict) or target.get("type") != "process":
387
+ continue
388
+ attributes = target.get("attributes") or {}
389
+ if not isinstance(attributes, dict):
390
+ continue
391
+ process_exe_match = _same_executable_identity(attributes.get("exe"), declaration)
392
+ cmdline = attributes.get("cmdline")
393
+ if isinstance(cmdline, list) and cmdline and all(isinstance(item, str) for item in cmdline):
394
+ cmdline_match = _same_executable_identity(cmdline[0], declaration)
395
+ argv_tail_match = (
396
+ len(declaration.command_argv) > 1
397
+ and len(cmdline) == len(declaration.command_argv)
398
+ and tuple(cmdline[1:]) == declaration.command_argv[1:]
399
+ )
400
+ if not process_exe_match and not cmdline_match and not argv_tail_match:
401
+ continue
402
+ process = deepcopy(target)
403
+ if process is None:
404
+ continue
405
+
406
+ process_id = process.get("id")
407
+ if not isinstance(process_id, str) or not process_id:
408
+ continue
409
+ candidate = candidates.get(process_id)
410
+ if candidate is None:
411
+ candidate = _Candidate(
412
+ process=process,
413
+ latest_timestamp=timestamp,
414
+ latest_timestamp_text=str(event.get("timestamp")),
415
+ )
416
+ candidates[process_id] = candidate
417
+ candidate.observe(
418
+ event,
419
+ timestamp=timestamp,
420
+ exec_match=exec_match,
421
+ process_exe_match=process_exe_match,
422
+ cmdline_match=cmdline_match,
423
+ argv_tail_match=argv_tail_match,
424
+ )
425
+ return candidates
426
+
427
+
428
+ def _derived_event(
429
+ declaration: _Declaration,
430
+ candidate: _Candidate,
431
+ *,
432
+ session_id: str,
433
+ max_window_ms: int,
434
+ ) -> dict[str, Any]:
435
+ method, confidence = candidate.method_and_confidence()
436
+ support = set(candidate.supporting_event_ids)
437
+ declaration_event_id = declaration.event.get("event_id")
438
+ if isinstance(declaration_event_id, str) and declaration_event_id:
439
+ support.add(declaration_event_id)
440
+
441
+ process_id = str(candidate.process.get("id"))
442
+ tool_call_id = str(declaration.tool_call.get("id"))
443
+ digest_source = "|".join(
444
+ [session_id, tool_call_id, process_id, method, *sorted(support)]
445
+ ).encode("utf-8", errors="replace")
446
+ digest = hashlib.sha256(digest_source).hexdigest()[:24]
447
+ delta_ms = max(
448
+ 0.0,
449
+ (candidate.latest_timestamp - declaration.timestamp).total_seconds() * 1000.0,
450
+ )
451
+ return {
452
+ "schema_version": SCHEMA_VERSION,
453
+ "event_id": f"inference:tool-process:{digest}",
454
+ "session_id": session_id,
455
+ "timestamp": candidate.latest_timestamp_text,
456
+ "event_type": _EVENT_TYPE,
457
+ "relation": _RELATION,
458
+ "source": deepcopy(declaration.tool_call),
459
+ "target": deepcopy(candidate.process),
460
+ "sequence": None,
461
+ "attributes": {
462
+ "backend": "inference",
463
+ "attribution": "execweave_correlation",
464
+ "evidence_source": "derived",
465
+ "causal": False,
466
+ "inferred": True,
467
+ "inference_method": method,
468
+ "confidence": confidence,
469
+ "confidence_semantics": "heuristic_score_not_probability",
470
+ "candidate_count": 1,
471
+ "declared_command_head": declaration.command_head,
472
+ "declared_command_token": declaration.command_token,
473
+ "tool_call_id": tool_call_id,
474
+ "command_entity_id": declaration.command_entity.get("id"),
475
+ "time_delta_ms": round(delta_ms, 3),
476
+ "max_window_ms": max_window_ms,
477
+ "supporting_event_ids": sorted(support),
478
+ },
479
+ }
480
+
481
+
482
+ def correlate_tool_process(
483
+ input_path: str | Path,
484
+ output_path: str | Path,
485
+ *,
486
+ max_window_ms: int = 3000,
487
+ ) -> CorrelationResult:
488
+ if max_window_ms <= 0:
489
+ raise ValueError("max_window_ms must be greater than zero")
490
+ source = Path(input_path).expanduser().resolve()
491
+ output = Path(output_path).expanduser().resolve()
492
+ if output.exists() and output.stat().st_size > 0:
493
+ raise FileExistsError(f"ExecWeave correlation output already exists: {output}")
494
+
495
+ validation = validate_event_stream(source, require_complete_session=True)
496
+ if not validation.valid:
497
+ raise ValueError("invalid input event stream: " + "; ".join(validation.errors))
498
+ events = _load_jsonl(source)
499
+ session_id = validation.session_ids[0]
500
+ declarations = _declarations(events)
501
+ results = _result_times(events)
502
+ derived: list[dict[str, Any]] = []
503
+ skipped_unsupported = 0
504
+ skipped_no_match = 0
505
+ skipped_ambiguous = 0
506
+
507
+ for position, declaration in enumerate(declarations):
508
+ if not declaration.command_head:
509
+ skipped_unsupported += 1
510
+ continue
511
+
512
+ window_end = declaration.timestamp + timedelta(milliseconds=max_window_ms)
513
+ tool_call_id = declaration.tool_call.get("id")
514
+ if isinstance(tool_call_id, str):
515
+ for result_time in results.get(tool_call_id, []):
516
+ if result_time >= declaration.timestamp:
517
+ window_end = min(window_end, result_time)
518
+ break
519
+ if position + 1 < len(declarations):
520
+ next_time = declarations[position + 1].timestamp
521
+ if next_time > declaration.timestamp:
522
+ window_end = min(window_end, next_time)
523
+
524
+ candidates = _candidate_processes(
525
+ events,
526
+ declaration=declaration,
527
+ start=declaration.timestamp,
528
+ end=window_end,
529
+ )
530
+ if not candidates:
531
+ skipped_no_match += 1
532
+ continue
533
+ if len(candidates) != 1:
534
+ skipped_ambiguous += 1
535
+ continue
536
+ derived.append(
537
+ _derived_event(
538
+ declaration,
539
+ next(iter(candidates.values())),
540
+ session_id=session_id,
541
+ max_window_ms=max_window_ms,
542
+ )
543
+ )
544
+
545
+ starts = [event for event in events if event.get("event_type") == "session.started"]
546
+ finishes = [event for event in events if event.get("event_type") == "session.finished"]
547
+ if len(starts) != 1 or len(finishes) != 1:
548
+ raise ValueError("input event stream must contain exactly one session start and finish")
549
+
550
+ body = [
551
+ deepcopy(event)
552
+ for event in events
553
+ if event.get("event_type") not in {"session.started", "session.finished"}
554
+ ]
555
+ decorated: list[tuple[datetime, int, int, dict[str, Any]]] = []
556
+ for index, event in enumerate(body):
557
+ decorated.append((_event_time(event, index=index + 1), 0, index, event))
558
+ for index, event in enumerate(derived):
559
+ decorated.append((_event_time(event, index=index + 1), 1, index, event))
560
+ decorated.sort(key=lambda item: (item[0], item[1], item[2]))
561
+
562
+ correlated = [deepcopy(starts[0]), *[item[3] for item in decorated], deepcopy(finishes[0])]
563
+ for sequence, event in enumerate(correlated, start=1):
564
+ event["sequence"] = sequence
565
+
566
+ output.parent.mkdir(parents=True, exist_ok=True)
567
+ fd, temp_name = tempfile.mkstemp(
568
+ prefix=".execweave-correlate-",
569
+ suffix=".jsonl",
570
+ dir=output.parent,
571
+ )
572
+ os.close(fd)
573
+ temp_path = Path(temp_name)
574
+ try:
575
+ temp_path.write_text(
576
+ "".join(
577
+ json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n"
578
+ for event in correlated
579
+ ),
580
+ encoding="utf-8",
581
+ )
582
+ correlated_validation = validate_event_stream(temp_path, require_complete_session=True)
583
+ if not correlated_validation.valid:
584
+ raise ValueError(
585
+ "correlated event stream is invalid: "
586
+ + "; ".join(correlated_validation.errors)
587
+ )
588
+ temp_path.replace(output)
589
+ finally:
590
+ if temp_path.exists():
591
+ temp_path.unlink()
592
+
593
+ return CorrelationResult(
594
+ session_id=session_id,
595
+ input_event_count=len(events),
596
+ output_event_count=len(correlated),
597
+ tool_calls_considered=len(declarations),
598
+ correlated_tool_calls=len(derived),
599
+ skipped_unsupported=skipped_unsupported,
600
+ skipped_no_match=skipped_no_match,
601
+ skipped_ambiguous=skipped_ambiguous,
602
+ max_window_ms=max_window_ms,
603
+ output=str(output),
604
+ )