cctally 1.67.1 → 1.69.0

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 (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_lib_jsonl.py CHANGED
@@ -22,12 +22,15 @@ from __future__ import annotations
22
22
 
23
23
  import datetime as dt
24
24
  import json
25
+ import math
25
26
  import pathlib
26
27
  import re
27
28
  import sys
28
29
  from dataclasses import dataclass, field
29
30
  from typing import Any
30
31
 
32
+ from _lib_source_identity import canonical_identity_from_root_key
33
+
31
34
 
32
35
  def _eprint(*args: Any) -> None:
33
36
  print(*args, file=sys.stderr)
@@ -336,6 +339,72 @@ class _CodexIterState:
336
339
  lines_malformed: int = 0
337
340
  token_events_skipped: int = 0
338
341
  skip_reasons: dict = field(default_factory=dict)
342
+ thread: "CodexThreadMetadata | None" = None
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class CodexThreadMetadata:
347
+ """Source facts established by one ``session_meta`` physical record."""
348
+ source_root_key: str | None
349
+ source_path: str
350
+ native_thread_id: str | None
351
+ root_thread_id: str | None
352
+ parent_thread_id: str | None
353
+ conversation_key: str | None
354
+ cwd: str | None
355
+ git_json: str | None
356
+ source_kind: str | None
357
+ thread_source_json: str | None
358
+ model_provider: str | None
359
+ context_window: int | None
360
+
361
+
362
+ @dataclass(frozen=True)
363
+ class CodexQuotaObservation:
364
+ """One validated native quota window from a physical rollout record."""
365
+ source: str
366
+ source_root_key: str | None
367
+ source_path: str
368
+ line_offset: int
369
+ captured_at_utc: str
370
+ observed_slot: str
371
+ logical_limit_key: str
372
+ limit_id: str | None
373
+ limit_name: str | None
374
+ window_minutes: int
375
+ used_percent: float
376
+ resets_at_utc: str
377
+ plan_type: str | None
378
+ individual_limit_json: str | None
379
+ reached_type: str | None
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class CodexPhysicalEvent:
384
+ """Complete canonical payload retained for one valid JSON object line."""
385
+ source_path: str
386
+ line_offset: int
387
+ source_root_key: str | None
388
+ conversation_key: str | None
389
+ native_thread_id: str | None
390
+ root_thread_id: str | None
391
+ parent_thread_id: str | None
392
+ timestamp_utc: str | None
393
+ record_type: str | None
394
+ event_type: str | None
395
+ turn_id: str | None
396
+ call_id: str | None
397
+ payload_json: str
398
+
399
+
400
+ @dataclass(frozen=True)
401
+ class CodexFusedEmission:
402
+ """All source-derived facts emitted after parsing one physical record once."""
403
+ line_offset: int
404
+ event: CodexPhysicalEvent
405
+ accounting: CodexEntry | None
406
+ quotas: tuple[CodexQuotaObservation, ...]
407
+ thread: CodexThreadMetadata | None
339
408
 
340
409
 
341
410
  def _codex_skip(state: "_CodexIterState", reason: str) -> None:
@@ -343,195 +412,493 @@ def _codex_skip(state: "_CodexIterState", reason: str) -> None:
343
412
  state.skip_reasons[reason] = state.skip_reasons.get(reason, 0) + 1
344
413
 
345
414
 
346
- def _iter_codex_jsonl_entries_with_offsets(
415
+ _QUOTA_ENVELOPE_FIELDS = frozenset((
416
+ "credits", "plan_type", "limit_id", "limit_name", "individual_limit",
417
+ "rate_limit_reached_type",
418
+ ))
419
+
420
+
421
+ def _codex_string(value: object) -> str | None:
422
+ return value if isinstance(value, str) and value.strip() else None
423
+
424
+
425
+ def _codex_canonical_json(value: object) -> str:
426
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
427
+ allow_nan=False)
428
+
429
+
430
+ def _parse_codex_timestamp(value: object) -> dt.datetime | None:
431
+ if not isinstance(value, str) or not value.strip():
432
+ return None
433
+ try:
434
+ parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
435
+ except ValueError:
436
+ return None
437
+ if parsed.tzinfo is None:
438
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
439
+ return parsed
440
+
441
+
442
+ def _format_codex_timestamp(value: dt.datetime) -> str:
443
+ return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
444
+
445
+
446
+ def _finite_number(value: object) -> float | None:
447
+ # Quota JSON fields are typed numerics. Do not coerce strings: a malformed
448
+ # direct value must yield to a valid typed fallback in payload.info.
449
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
450
+ return None
451
+ try:
452
+ number = float(value)
453
+ except (TypeError, ValueError, OverflowError):
454
+ return None
455
+ return number if math.isfinite(number) else None
456
+
457
+
458
+ def _json_value_is_finite(value: object) -> bool:
459
+ """Whether a decoded JSON value can be serialized canonically.
460
+
461
+ ``json.loads`` rejects the literal NaN/Infinity constants through
462
+ ``parse_constant``, but a syntactically valid exponent such as ``1e400``
463
+ is decoded as ``float('inf')``. Reject it before a physical event reaches
464
+ canonical serialization with ``allow_nan=False``.
465
+ """
466
+ if isinstance(value, float):
467
+ return math.isfinite(value)
468
+ if isinstance(value, dict):
469
+ return all(_json_value_is_finite(item) for item in value.values())
470
+ if isinstance(value, list):
471
+ return all(_json_value_is_finite(item) for item in value)
472
+ return True
473
+
474
+
475
+ def _positive_whole_number(value: object) -> int | None:
476
+ number = _finite_number(value)
477
+ if number is None or number <= 0 or not number.is_integer():
478
+ return None
479
+ return int(number)
480
+
481
+
482
+ def _quota_reset_at(value: object) -> str | None:
483
+ numeric = _finite_number(value)
484
+ if numeric is not None:
485
+ try:
486
+ return _format_codex_timestamp(
487
+ dt.datetime.fromtimestamp(numeric, tz=dt.timezone.utc)
488
+ )
489
+ except (OverflowError, OSError, ValueError):
490
+ return None
491
+ parsed = _parse_codex_timestamp(value)
492
+ return _format_codex_timestamp(parsed) if parsed is not None else None
493
+
494
+
495
+ def _first_valid_string(*values: object) -> str | None:
496
+ for value in values:
497
+ string = _codex_string(value)
498
+ if string is not None:
499
+ return string
500
+ return None
501
+
502
+
503
+ def _canonical_container(value: object) -> str | None:
504
+ if not isinstance(value, (dict, list)):
505
+ return None
506
+ return _codex_canonical_json(value)
507
+
508
+
509
+ def _thread_metadata_from_session_meta(
510
+ payload: dict[str, Any], path_str: str, source_root_key: str | None,
511
+ ) -> CodexThreadMetadata:
512
+ accounting_id = _codex_string(payload.get("id"))
513
+ native_thread_id = _codex_string(payload.get("session_id")) or accounting_id
514
+ root_thread_id = _codex_string(payload.get("thread_source"))
515
+ parent_thread_id = _codex_string(payload.get("forked_from_id"))
516
+ conversation_key = None
517
+ if native_thread_id is not None and root_thread_id is not None:
518
+ conversation_key = canonical_identity_from_root_key(
519
+ "codex", "conversation", source_root_key, native_thread_id, root_thread_id
520
+ )
521
+ return CodexThreadMetadata(
522
+ source_root_key=source_root_key,
523
+ source_path=path_str,
524
+ native_thread_id=native_thread_id,
525
+ root_thread_id=root_thread_id,
526
+ parent_thread_id=parent_thread_id,
527
+ conversation_key=conversation_key,
528
+ cwd=_codex_string(payload.get("cwd")),
529
+ git_json=_canonical_container(payload.get("git")),
530
+ source_kind=_codex_string(payload.get("source")),
531
+ thread_source_json=_canonical_container(payload.get("thread_source")),
532
+ model_provider=_codex_string(payload.get("model_provider")),
533
+ context_window=_positive_whole_number(payload.get("context_window")),
534
+ )
535
+
536
+
537
+ def _first_valid_individual_limit_json(
538
+ direct: dict[str, Any], fallback: dict[str, Any],
539
+ ) -> str | None:
540
+ for envelope in (direct, fallback):
541
+ value = envelope.get("individual_limit")
542
+ if isinstance(value, dict):
543
+ return _codex_canonical_json(value)
544
+ if _finite_number(value) is not None:
545
+ return _codex_canonical_json(value)
546
+ return None
547
+
548
+
549
+ def _first_valid_quota_number(
550
+ direct: dict[str, Any], fallback: dict[str, Any], key: str,
551
+ *, minimum: float | None = None, maximum: float | None = None,
552
+ ) -> float | None:
553
+ for window in (direct, fallback):
554
+ number = _finite_number(window.get(key))
555
+ if number is None:
556
+ continue
557
+ if minimum is not None and number < minimum:
558
+ continue
559
+ if maximum is not None and number > maximum:
560
+ continue
561
+ return number
562
+ return None
563
+
564
+
565
+ def _first_valid_window_minutes(
566
+ direct: dict[str, Any], fallback: dict[str, Any],
567
+ ) -> int | None:
568
+ for window in (direct, fallback):
569
+ minutes = _positive_whole_number(window.get("window_minutes"))
570
+ if minutes is not None:
571
+ return minutes
572
+ return None
573
+
574
+
575
+ def _first_valid_reset_at(
576
+ direct: dict[str, Any], fallback: dict[str, Any],
577
+ ) -> str | None:
578
+ for window in (direct, fallback):
579
+ reset_at = _quota_reset_at(window.get("resets_at"))
580
+ if reset_at is not None:
581
+ return reset_at
582
+ return None
583
+
584
+
585
+ def _codex_logical_limit_key(
586
+ source_root_key: str | None, limit_id: str | None, observed_slot: str,
587
+ window_minutes: int,
588
+ ) -> str:
589
+ return _codex_canonical_json({
590
+ "limitId": limit_id,
591
+ "observedSlot": observed_slot,
592
+ "source": "codex",
593
+ "sourceRootKey": source_root_key,
594
+ "windowMinutes": window_minutes,
595
+ })
596
+
597
+
598
+ def _codex_quota_observations(
599
+ obj: dict[str, Any], payload: dict[str, Any], path_str: str, line_offset: int,
600
+ source_root_key: str | None,
601
+ ) -> tuple[CodexQuotaObservation, ...]:
602
+ captured_at = _parse_codex_timestamp(obj.get("timestamp"))
603
+ if captured_at is None:
604
+ return ()
605
+ direct = payload.get("rate_limits")
606
+ info = payload.get("info")
607
+ fallback = info.get("rate_limits") if isinstance(info, dict) else None
608
+ direct = direct if isinstance(direct, dict) else {}
609
+ fallback = fallback if isinstance(fallback, dict) else {}
610
+ if not direct and not fallback:
611
+ return ()
612
+
613
+ slots = sorted({
614
+ slot for envelope in (direct, fallback) for slot, value in envelope.items()
615
+ if slot not in _QUOTA_ENVELOPE_FIELDS and isinstance(value, dict)
616
+ })
617
+ observations: list[CodexQuotaObservation] = []
618
+ for slot in slots:
619
+ direct_window = direct.get(slot)
620
+ fallback_window = fallback.get(slot)
621
+ direct_window = direct_window if isinstance(direct_window, dict) else {}
622
+ fallback_window = fallback_window if isinstance(fallback_window, dict) else {}
623
+ used_percent = _first_valid_quota_number(
624
+ direct_window, fallback_window, "used_percent", minimum=0.0, maximum=100.0
625
+ )
626
+ window_minutes = _first_valid_window_minutes(direct_window, fallback_window)
627
+ resets_at_utc = _first_valid_reset_at(direct_window, fallback_window)
628
+ if used_percent is None or window_minutes is None or resets_at_utc is None:
629
+ continue
630
+ limit_id = _first_valid_string(
631
+ direct.get("limit_id"), fallback.get("limit_id")
632
+ )
633
+ observations.append(CodexQuotaObservation(
634
+ source="codex",
635
+ source_root_key=source_root_key,
636
+ source_path=path_str,
637
+ line_offset=line_offset,
638
+ captured_at_utc=_format_codex_timestamp(captured_at),
639
+ observed_slot=slot,
640
+ logical_limit_key=_codex_logical_limit_key(
641
+ source_root_key, limit_id, slot, window_minutes
642
+ ),
643
+ limit_id=limit_id,
644
+ limit_name=_first_valid_string(
645
+ direct.get("limit_name"), fallback.get("limit_name")
646
+ ),
647
+ window_minutes=window_minutes,
648
+ used_percent=used_percent,
649
+ resets_at_utc=resets_at_utc,
650
+ plan_type=_first_valid_string(
651
+ direct.get("plan_type"), fallback.get("plan_type")
652
+ ),
653
+ individual_limit_json=_first_valid_individual_limit_json(direct, fallback),
654
+ reached_type=_first_valid_string(
655
+ direct.get("rate_limit_reached_type"),
656
+ fallback.get("rate_limit_reached_type"),
657
+ ),
658
+ ))
659
+ return tuple(observations)
660
+
661
+
662
+ def _reject_nonfinite_json_constant(value: str) -> None:
663
+ raise ValueError(f"non-finite JSON constant: {value}")
664
+
665
+
666
+ def _seed_codex_iter_state(
667
+ state: _CodexIterState, initial_session_id: str | None,
668
+ initial_model: str | None, initial_total_tokens: int,
669
+ ) -> None:
670
+ if state.session_id is None and initial_session_id is not None:
671
+ state.session_id = initial_session_id
672
+ if state.model is None and initial_model is not None:
673
+ state.model = initial_model
674
+ if state.total_tokens == 0 and initial_total_tokens:
675
+ state.total_tokens = int(initial_total_tokens)
676
+
677
+
678
+ def _event_from_record(
679
+ obj: dict[str, Any], path_str: str, line_offset: int,
680
+ source_root_key: str | None, thread: CodexThreadMetadata | None,
681
+ ) -> CodexPhysicalEvent:
682
+ payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
683
+ record_type = _codex_string(obj.get("type")) or _codex_string(obj.get("record_type"))
684
+ return CodexPhysicalEvent(
685
+ source_path=path_str,
686
+ line_offset=line_offset,
687
+ source_root_key=source_root_key,
688
+ conversation_key=thread.conversation_key if thread is not None else None,
689
+ native_thread_id=thread.native_thread_id if thread is not None else None,
690
+ root_thread_id=thread.root_thread_id if thread is not None else None,
691
+ parent_thread_id=thread.parent_thread_id if thread is not None else None,
692
+ timestamp_utc=(
693
+ _format_codex_timestamp(timestamp)
694
+ if (timestamp := _parse_codex_timestamp(obj.get("timestamp"))) is not None
695
+ else None
696
+ ),
697
+ record_type=record_type,
698
+ event_type=_codex_string(payload.get("type")),
699
+ turn_id=_codex_string(payload.get("turn_id")),
700
+ call_id=_codex_string(payload.get("call_id")),
701
+ payload_json=_codex_canonical_json(obj),
702
+ )
703
+
704
+
705
+ def _accounting_from_record(
706
+ obj: dict[str, Any], payload: dict[str, Any], path_str: str,
707
+ state: _CodexIterState, last_total_tokens: int,
708
+ filename_uuid: str | None, filename_session_id_warned: bool,
709
+ ) -> tuple[CodexEntry | None, int, bool]:
710
+ if obj.get("type") != "event_msg" or payload.get("type") != "token_count":
711
+ return None, last_total_tokens, filename_session_id_warned
712
+ info = payload.get("info")
713
+ if info is None:
714
+ return None, last_total_tokens, filename_session_id_warned
715
+ if not isinstance(info, dict):
716
+ _codex_skip(state, "info-non-dict")
717
+ return None, last_total_tokens, filename_session_id_warned
718
+ last_token_usage = info.get("last_token_usage")
719
+ if not isinstance(last_token_usage, dict):
720
+ _codex_skip(state, "no-last-token-usage")
721
+ return None, last_total_tokens, filename_session_id_warned
722
+
723
+ total_token_usage = info.get("total_token_usage")
724
+ if isinstance(total_token_usage, dict):
725
+ try:
726
+ cumulative = int(total_token_usage.get("total_tokens") or 0)
727
+ except (TypeError, ValueError):
728
+ cumulative = 0
729
+ if cumulative <= last_total_tokens:
730
+ return None, last_total_tokens, filename_session_id_warned
731
+ else:
732
+ cumulative = None
733
+
734
+ timestamp = _parse_codex_timestamp(obj.get("timestamp"))
735
+ if timestamp is None:
736
+ _codex_skip(state, "bad-timestamp")
737
+ return None, last_total_tokens, filename_session_id_warned
738
+ session_id = state.session_id
739
+ if session_id is None:
740
+ session_id = filename_uuid
741
+ if session_id is not None and not filename_session_id_warned:
742
+ _eprint(
743
+ f"[codex] session_meta not seen in {path_str}; "
744
+ f"falling back to filename UUID {session_id}"
745
+ )
746
+ filename_session_id_warned = True
747
+ if session_id is None:
748
+ _codex_skip(state, "no-session-id")
749
+ return None, last_total_tokens, filename_session_id_warned
750
+
751
+ def token_count(key: str) -> int:
752
+ try:
753
+ return int(last_token_usage.get(key) or 0)
754
+ except (TypeError, ValueError):
755
+ return 0
756
+
757
+ entry = CodexEntry(
758
+ timestamp=timestamp,
759
+ session_id=session_id,
760
+ model=state.model or "unknown",
761
+ input_tokens=token_count("input_tokens"),
762
+ cached_input_tokens=token_count("cached_input_tokens"),
763
+ output_tokens=token_count("output_tokens"),
764
+ reasoning_output_tokens=token_count("reasoning_output_tokens"),
765
+ total_tokens=token_count("total_tokens"),
766
+ source_path=path_str,
767
+ )
768
+ if cumulative is not None:
769
+ state.total_tokens = cumulative
770
+ last_total_tokens = cumulative
771
+ return entry, last_total_tokens, filename_session_id_warned
772
+
773
+
774
+ def _iter_codex_fused_records_with_offsets(
347
775
  fh,
348
776
  path_str: str,
349
777
  *,
350
778
  initial_session_id: str | None = None,
351
779
  initial_model: str | None = None,
352
780
  initial_total_tokens: int = 0,
781
+ source_root_key: str | None = None,
353
782
  state: _CodexIterState | None = None,
354
783
  ):
355
- """Yield (line_offset, CodexEntry) for each billable `token_count` event.
356
-
357
- Maintains per-file state (session_id, model) as records are streamed.
358
- Callers performing a delta resume from non-zero byte offset should pass
359
- the previously-observed session_id/model as initial_session_id and
360
- initial_model so attribution stays correct even if the new byte range
361
- contains no fresh session_meta / turn_context record.
362
-
363
- If `state` is supplied it is updated in-place on every `session_meta`
364
- / `turn_context` record regardless of whether any subsequent
365
- `token_count` actually yields. This lets callers observe the iterator's
366
- terminal state even when the delta window ends on a metadata record —
367
- otherwise `last_model` would silently persist a stale value and the
368
- next resume would mis-attribute the first post-resume token_count.
369
-
370
- Skips token_count events with payload.info == None (rate-limit-only
371
- events). Falls back to filename-derived session_id with a one-shot warning
372
- if session_meta is never observed.
373
-
374
- Codex CLI emits multiple `token_count` events per completed turn (UI/
375
- turn_context updates re-emit the same `last_token_usage` while the
376
- cumulative `info.total_token_usage.total_tokens` stays flat). To avoid
377
- double-counting, we track the cumulative total across yields and skip
378
- any event whose cumulative total is not strictly greater than the
379
- previously-seen cumulative. Callers doing delta resumes should pass the
380
- last persisted cumulative as `initial_total_tokens`. If `total_token_usage`
381
- is missing or non-dict (older Codex builds), we fall back to yielding
382
- unconditionally — preserving legacy behavior on those rollouts.
383
-
384
- Readline()+tell() is used rather than `for line in fh` so byte offsets
385
- are accurate for resume-from-offset after partial ingests. Partial-tail
386
- lines (no trailing \\n) trigger a seek-back so the next sync re-reads
387
- the line once the newline is flushed.
784
+ """Yield typed facts for every complete, valid Codex JSONL object.
785
+
786
+ Binary readers retain physical byte offsets and strictly reject invalid UTF-8,
787
+ non-finite JSON constants, and non-object JSON. The mutable state carries
788
+ the shipped accounting resume contract alongside the latest thread facts.
388
789
  """
389
790
  if state is None:
390
791
  state = _CodexIterState()
391
- # Seed the tracker from the kwargs. Kwargs take priority only when the
392
- # caller-supplied state has no value yet — this preserves the existing
393
- # contract for callers that pass kwargs without a state object, while
394
- # letting callers who DO pass a pre-populated state see it honored.
395
- if state.session_id is None and initial_session_id is not None:
396
- state.session_id = initial_session_id
397
- if state.model is None and initial_model is not None:
398
- state.model = initial_model
399
- # #279 S3 F1: the state carries the REAL dedup watermark. Seeding
400
- # precedence mirrors session_id/model: a caller-supplied NON-ZERO state
401
- # wins; the kwarg seeds otherwise. (total_tokens has no None sentinel —
402
- # 0 IS the unset value; a genuine 0 watermark and "unset" are the same
403
- # thing: both mean "dedupe against nothing".)
404
- if state.total_tokens == 0 and initial_total_tokens:
405
- state.total_tokens = int(initial_total_tokens)
406
- last_total_tokens: int = state.total_tokens
407
- # Suppress the filename-UUID fallback warning when we already have a
408
- # seeded session_id (delta resume path). Without this, every resume
409
- # into a slice of the file that doesn't re-observe session_meta would
410
- # noisily warn even though attribution is correct.
792
+ _seed_codex_iter_state(state, initial_session_id, initial_model, initial_total_tokens)
793
+ last_total_tokens = state.total_tokens
411
794
  filename_session_id_warned = state.session_id is not None
412
795
  filename_uuid_match = _CODEX_FILENAME_UUID_RE.search(path_str)
413
796
  filename_uuid = filename_uuid_match.group(1) if filename_uuid_match else None
414
797
 
415
798
  while True:
416
- offset = fh.tell()
799
+ line_offset = fh.tell()
417
800
  line = fh.readline()
418
801
  if not line:
419
802
  return
420
- if not line.endswith("\n"):
421
- fh.seek(offset)
803
+ if isinstance(line, bytes):
804
+ complete = line.endswith(b"\n")
805
+ text: str | None
806
+ try:
807
+ text = line.decode("utf-8")
808
+ except UnicodeDecodeError:
809
+ text = None
810
+ else:
811
+ complete = line.endswith("\n")
812
+ text = line
813
+ if not complete:
814
+ fh.seek(line_offset)
422
815
  return
423
- stripped = line.strip()
816
+ if text is None:
817
+ state.lines_seen += 1
818
+ state.lines_malformed += 1
819
+ continue
820
+ stripped = text.strip()
424
821
  if not stripped:
425
822
  continue
426
- state.lines_seen += 1 # #279 S2 F1: non-blank lines (malformed included)
823
+ state.lines_seen += 1
427
824
  try:
428
- obj = json.loads(stripped)
429
- except json.JSONDecodeError:
825
+ obj = json.loads(stripped, parse_constant=_reject_nonfinite_json_constant)
826
+ except (json.JSONDecodeError, ValueError, TypeError):
430
827
  state.lines_malformed += 1
431
828
  continue
432
-
433
- rtype = obj.get("type")
434
- payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
435
-
436
- if rtype == "session_meta":
437
- sid = payload.get("id")
438
- if isinstance(sid, str) and sid:
439
- state.session_id = sid
440
- continue
441
-
442
- if rtype == "turn_context":
443
- m = payload.get("model")
444
- if isinstance(m, str) and m.strip():
445
- state.model = m.strip()
446
- continue
447
-
448
- if rtype != "event_msg":
449
- continue
450
-
451
- if payload.get("type") != "token_count":
452
- continue
453
- info = payload.get("info")
454
- if info is None:
455
- continue # rate-limit-only event — normal, not drift
456
- if not isinstance(info, dict):
457
- _codex_skip(state, "info-non-dict")
829
+ if not isinstance(obj, dict):
830
+ state.lines_malformed += 1
458
831
  continue
459
- ltu = info.get("last_token_usage")
460
- if not isinstance(ltu, dict):
461
- _codex_skip(state, "no-last-token-usage")
832
+ if not _json_value_is_finite(obj):
833
+ state.lines_malformed += 1
462
834
  continue
463
835
 
464
- # Dedupe re-emitted token_count events. Codex re-emits `last_token_usage`
465
- # on UI/turn_context updates with a flat `total_token_usage.total_tokens`;
466
- # only yield once per actual turn by requiring the cumulative to strictly
467
- # advance. If `total_token_usage` is missing or non-dict (older Codex
468
- # builds), skip the guard and yield — preserving legacy behavior.
469
- ttu = info.get("total_token_usage")
470
- if isinstance(ttu, dict):
471
- try:
472
- cumulative = int(ttu.get("total_tokens") or 0)
473
- except (TypeError, ValueError):
474
- cumulative = 0
475
- if cumulative <= last_total_tokens:
476
- continue
477
- else:
478
- cumulative = None # type: ignore[assignment]
836
+ payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
837
+ thread_update = None
838
+ if obj.get("type") == "session_meta":
839
+ session_id = _codex_string(payload.get("id"))
840
+ if session_id is not None:
841
+ state.session_id = session_id
842
+ thread_update = _thread_metadata_from_session_meta(
843
+ payload, path_str, source_root_key
844
+ )
845
+ state.thread = thread_update
846
+ elif obj.get("type") == "turn_context":
847
+ model = _codex_string(payload.get("model"))
848
+ if model is not None:
849
+ state.model = model.strip()
850
+
851
+ event = _event_from_record(
852
+ obj, path_str, line_offset, source_root_key, state.thread
853
+ )
854
+ accounting, last_total_tokens, filename_session_id_warned = _accounting_from_record(
855
+ obj, payload, path_str, state, last_total_tokens, filename_uuid,
856
+ filename_session_id_warned,
857
+ )
858
+ quotas = _codex_quota_observations(
859
+ obj, payload, path_str, line_offset, source_root_key
860
+ )
861
+ yield CodexFusedEmission(
862
+ line_offset=line_offset,
863
+ event=event,
864
+ accounting=accounting,
865
+ quotas=quotas,
866
+ thread=thread_update,
867
+ )
479
868
 
480
- ts_raw = obj.get("timestamp")
481
- if not isinstance(ts_raw, str) or not ts_raw.strip():
482
- _codex_skip(state, "bad-timestamp")
483
- continue
484
- try:
485
- ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
486
- if ts.tzinfo is None:
487
- ts = ts.replace(tzinfo=dt.timezone.utc)
488
- except ValueError:
489
- _codex_skip(state, "bad-timestamp")
490
- continue
491
869
 
492
- session_id = state.session_id
493
- if session_id is None:
494
- session_id = filename_uuid
495
- if session_id is not None and not filename_session_id_warned:
496
- _eprint(
497
- f"[codex] session_meta not seen in {path_str}; "
498
- f"falling back to filename UUID {session_id}"
499
- )
500
- filename_session_id_warned = True
501
- if session_id is None:
502
- # No session_meta and no parseable filename UUID — skip row.
503
- _codex_skip(state, "no-session-id")
504
- continue
505
-
506
- model = state.model or "unknown"
507
-
508
- def _int(key: str) -> int:
509
- v = ltu.get(key)
510
- try:
511
- return int(v or 0)
512
- except (TypeError, ValueError):
513
- return 0
514
-
515
- yield (
516
- offset,
517
- CodexEntry(
518
- timestamp=ts,
519
- session_id=session_id,
520
- model=model,
521
- input_tokens=_int("input_tokens"),
522
- cached_input_tokens=_int("cached_input_tokens"),
523
- output_tokens=_int("output_tokens"),
524
- reasoning_output_tokens=_int("reasoning_output_tokens"),
525
- total_tokens=_int("total_tokens"),
526
- source_path=path_str,
527
- ),
528
- )
529
- # Advance the cumulative watermark only after a successful yield so
530
- # resume-from-offset continues to dedupe against the last counted turn.
531
- # #279 S3 F1: stamp the state too — after the iterator drains,
532
- # state.total_tokens IS the guard's terminal watermark, which the
533
- # caller persists (the old reconstructed initial+Σ(per-turn) sum could
534
- # diverge from it and double-count/skip on resume).
535
- if isinstance(ttu, dict) and cumulative is not None:
536
- last_total_tokens = cumulative
537
- state.total_tokens = cumulative
870
+ def _iter_codex_jsonl_entries_with_offsets(
871
+ fh,
872
+ path_str: str,
873
+ *,
874
+ initial_session_id: str | None = None,
875
+ initial_model: str | None = None,
876
+ initial_total_tokens: int = 0,
877
+ state: _CodexIterState | None = None,
878
+ ):
879
+ """Compatibility wrapper exposing only existing accounting emissions."""
880
+ binary_fh = fh
881
+ text_fh = None
882
+ try:
883
+ if isinstance(fh.read(0), str) and hasattr(fh, "buffer"):
884
+ text_fh = fh
885
+ position = fh.tell()
886
+ fh.seek(position)
887
+ binary_fh = fh.buffer
888
+ binary_fh.seek(position)
889
+ except (AttributeError, OSError, TypeError):
890
+ pass
891
+ try:
892
+ for emission in _iter_codex_fused_records_with_offsets(
893
+ binary_fh,
894
+ path_str,
895
+ initial_session_id=initial_session_id,
896
+ initial_model=initial_model,
897
+ initial_total_tokens=initial_total_tokens,
898
+ state=state,
899
+ ):
900
+ if emission.accounting is not None:
901
+ yield emission.line_offset, emission.accounting
902
+ finally:
903
+ if text_fh is not None:
904
+ text_fh.seek(binary_fh.tell())