cctally 1.111.0 → 1.113.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 (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_cache.py +14 -7
  4. package/bin/_cctally_core.py +9 -1
  5. package/bin/_cctally_dashboard_envelope.py +7 -6
  6. package/bin/_cctally_dashboard_sources.py +90 -55
  7. package/bin/_cctally_doctor.py +73 -27
  8. package/bin/_cctally_forecast.py +2 -1
  9. package/bin/_cctally_journal.py +147 -6
  10. package/bin/_cctally_milestones.py +29 -3
  11. package/bin/_cctally_quota.py +10 -1
  12. package/bin/_cctally_record.py +136 -4
  13. package/bin/_cctally_rederive.py +311 -8
  14. package/bin/_cctally_source_analytics.py +20 -8
  15. package/bin/_cctally_tui.py +546 -45
  16. package/bin/_lib_codex_conversation_query.py +40 -4
  17. package/bin/_lib_codex_metadata.py +57 -11
  18. package/bin/_lib_cost_provenance.py +303 -0
  19. package/bin/_lib_dashboard_sources.py +263 -28
  20. package/bin/_lib_doctor.py +2 -2
  21. package/bin/_lib_ingest_frontier.py +137 -38
  22. package/bin/_lib_pricing.py +29 -1
  23. package/bin/_lib_rederive.py +704 -36
  24. package/bin/_lib_snapshot_cache.py +332 -9
  25. package/dashboard/static/assets/{ConversationsView-BB9vUiL6.js → ConversationsView-CEZ-BUyi.js} +1 -1
  26. package/dashboard/static/assets/{DoctorModal-DFY2iPDf.js → DoctorModal-CE1oH_Zy.js} +1 -1
  27. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +1 -0
  28. package/dashboard/static/assets/{ProjectsDrillPanel-DAv39uSo.js → ProjectsDrillPanel-B3DZoCeD.js} +1 -1
  29. package/dashboard/static/assets/{SourceDetailModal-DSAWo2Fp.js → SourceDetailModal-CHp9EgLP.js} +1 -1
  30. package/dashboard/static/assets/{UpdateModal-R_9dOVnh.js → UpdateModal-5iKST6jo.js} +1 -1
  31. package/dashboard/static/assets/{index-DdU62sov.js → index-Cm0VTxJC.js} +2 -2
  32. package/dashboard/static/assets/index-Dt4tqf43.css +1 -0
  33. package/dashboard/static/assets/{outlineNavigation-BOCLcRa6.js → outlineNavigation-DkXGL8J6.js} +1 -1
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +2 -1
  36. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +0 -1
  37. package/dashboard/static/assets/index-COhxe3vU.css +0 -1
@@ -6,8 +6,10 @@ import datetime as dt
6
6
  import hashlib
7
7
  import json
8
8
  import math
9
+ import os
9
10
  import re
10
11
  import sqlite3
12
+ import time
11
13
  from dataclasses import dataclass
12
14
  from types import MappingProxyType
13
15
  from typing import Literal, Mapping
@@ -717,6 +719,54 @@ def reuse_coherent_source_state(
717
719
  return prior if _reusable_provider(prior) and prior.data_version == data_version else None
718
720
 
719
721
 
722
+ #: The complete warning-code set of a settled cycle-unavailable generation.
723
+ SETTLED_CODEX_CYCLE_WARNING_CODES = frozenset(("codex_cycle_unavailable",))
724
+
725
+
726
+ def settled_codex_cycle_unavailable(state: object) -> bool:
727
+ """Whether a Codex generation is the SETTLED cycle-unavailable state (#857).
728
+
729
+ The one Codex-only predicate both TUI retention legs consult. It names the
730
+ everyday state of an install that has not used Codex since its last weekly
731
+ reset: the build SUCCEEDED, every domain it publishes is current, and the
732
+ only thing withheld is the hero, because no native weekly boundary contains
733
+ ``now``. That is ``partial`` and ``fresh`` with data present and exactly one
734
+ warning code, ``codex_cycle_unavailable``.
735
+
736
+ It is deliberately narrower than "partial for a known cause". Every
737
+ neighbour still rebuilds: a composite with an incoherent projection (the
738
+ certificate-recovery case), an unresolved account scope (#819), incomplete
739
+ metadata (the retry kernel's own business), an unknown code, stale or
740
+ missing data, a failed aggregate fold, and a RETRYABLE metadata carrier,
741
+ which no reuse or retention gate may admit (#846). The cause is NOT added to
742
+ ``_lib_source_retry.RETAINABLE_PARTIAL_CAUSES``: that allowlist also decides
743
+ composite keys, and this state is not a throttled retry but a generation
744
+ with nothing left to retry.
745
+
746
+ This predicate states only the SHAPE. Whether the shape may be republished
747
+ this tick is the caller's question — its captured input identity must still
748
+ be current and its decision deadline must not have passed — because only
749
+ the caller holds the current identity and the clock.
750
+ """
751
+ if not isinstance(state, SourceDashboardState) or state.source != "codex":
752
+ return False
753
+ if (
754
+ state.availability != "partial"
755
+ or state.freshness != "fresh"
756
+ or state.data is None
757
+ or not state.data_version
758
+ ):
759
+ return False
760
+ if frozenset(
761
+ warning.code for warning in state.warnings
762
+ ) != SETTLED_CODEX_CYCLE_WARNING_CODES:
763
+ return False
764
+ health = state.metadata_health
765
+ if health is not None and bool(health.get("retryable")):
766
+ return False
767
+ return not aggregate_scope_failed(state)
768
+
769
+
720
770
  # === #556 S1 — the typed combined outcome (spec §3.5, §3.7) =================
721
771
  #
722
772
  # `combined` is the sum, over both providers, of that provider's accounting
@@ -987,15 +1037,7 @@ def _decorated_combined_leg(
987
1037
  }, ()
988
1038
 
989
1039
 
990
- def canonical_alerted_at(value: object) -> str:
991
- """Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
992
-
993
- #556 S3 §2.2. The union sorted on a field one writer never wrote, so a
994
- missing or malformed value must raise here rather than degrade to a
995
- sentinel that sorts silently. Sub-second precision is truncated, so two
996
- alerts firing in the same second compare equal and fall back to source
997
- order; no writer emits it today.
998
- """
1040
+ def _parse_alerted_at(value: object) -> dt.datetime:
999
1041
  if not isinstance(value, str) or not value:
1000
1042
  raise ValueError(f"alerted_at must be a non-empty ISO-8601 string, got {value!r}")
1001
1043
  try:
@@ -1004,25 +1046,96 @@ def canonical_alerted_at(value: object) -> str:
1004
1046
  raise ValueError(f"unparseable alerted_at {value!r}") from exc
1005
1047
  if parsed.tzinfo is None:
1006
1048
  raise ValueError(f"naive alerted_at {value!r}; an aware instant is required")
1007
- return parsed.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1049
+ return parsed.astimezone(dt.timezone.utc)
1050
+
1051
+
1052
+ def canonical_alerted_at(value: object) -> str:
1053
+ """Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
1054
+
1055
+ #556 S3 §2.2. The union sorted on a field one writer never wrote, so a
1056
+ missing or malformed value must raise here rather than degrade to a
1057
+ sentinel that sorts silently. A whole-second instant keeps its historical
1058
+ ``…SSZ`` spelling. #869 F14: a Codex budget crossing records the exact
1059
+ microsecond cutoff its spend scan used, so a sub-second instant keeps its
1060
+ six-digit fraction instead of being truncated into a tie with a Claude
1061
+ alert from the same second. This value is for publication; ordering uses
1062
+ :func:`alerted_at_order_key`, because two widths do not sort as text.
1063
+ """
1064
+ parsed = _parse_alerted_at(value)
1065
+ if parsed.microsecond:
1066
+ return parsed.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
1067
+ return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
1068
+
1069
+
1070
+ def alerted_at_order_key(value: object) -> str:
1071
+ """The full firing instant as fixed-width UTC text: ``…SS.ffffffZ``.
1072
+
1073
+ #869 F14. Every truncation and union across vendors compares firing
1074
+ instants, and a whole-second Claude alert must sort before a Codex alert
1075
+ fired later in the same second. Fixed width makes text order equal instant
1076
+ order, and :func:`alerted_at_order_key_sql` produces the same string in
1077
+ SQLite. Raises exactly as :func:`canonical_alerted_at` does.
1078
+ """
1079
+ return _parse_alerted_at(value).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
1080
+
1081
+
1082
+ def _alerted_at_fraction_sql(column: str) -> str:
1083
+ """The six-digit sub-second fraction of an ISO-8601 value, in SQLite.
1084
+
1085
+ SQLite's date functions keep only milliseconds, so the fraction is read
1086
+ from the text itself. A UTC offset is a whole number of minutes, so the
1087
+ source fraction is also the UTC instant's fraction.
1088
+ """
1089
+ tail = f"substr({column}, 21)"
1090
+ digits = (
1091
+ f"substr({tail}, 1, CASE "
1092
+ f"WHEN instr({tail}, 'Z') > 0 THEN instr({tail}, 'Z') - 1 "
1093
+ f"WHEN instr({tail}, '+') > 0 THEN instr({tail}, '+') - 1 "
1094
+ f"WHEN instr({tail}, '-') > 0 THEN instr({tail}, '-') - 1 "
1095
+ f"ELSE length({tail}) END)"
1096
+ )
1097
+ return (
1098
+ f"(CASE WHEN substr({column}, 20, 1) = '.' "
1099
+ f"THEN substr({digits} || '000000', 1, 6) ELSE '000000' END)"
1100
+ )
1101
+
1102
+
1103
+ def alerted_at_order_key_sql(column: str = "alerted_at") -> str:
1104
+ """The SQL twin of :func:`alerted_at_order_key`, for ``ORDER BY … LIMIT``.
1105
+
1106
+ #556 S3 §2.3 / #869 F14. Every per-axis ``LIMIT`` decides MEMBERSHIP, not
1107
+ merely order, so it must compare the full instant: two rows fired in the
1108
+ same second are otherwise tie-broken by an unrelated column and the newer
1109
+ one can be dropped before any later projection sees it. SQLite parses the
1110
+ offset for the seconds part and the text supplies the fraction, so for
1111
+ every aware spelling this returns the Python helper's string —
1112
+ ``tests/test_556_s3_alert_ordering.py`` pins that agreement. A naive value
1113
+ is read as UTC here where the Python helper raises; an unparseable value
1114
+ yields ``NULL``, which sorts last under ``DESC``. The expression names
1115
+ ``column`` several times, so a bound parameter must be numbered (``?1``).
1116
+ """
1117
+ return (
1118
+ f"(strftime('%Y-%m-%dT%H:%M:%S', {column}) || '.' || "
1119
+ f"{_alerted_at_fraction_sql(column)} || 'Z')"
1120
+ )
1008
1121
 
1009
1122
 
1010
1123
  def canonical_alerted_at_sql(column: str = "alerted_at") -> str:
1011
- """The SQL twin of :func:`canonical_alerted_at`, for ordering in SQLite.
1012
-
1013
- #556 S3 §2.3. Every per-axis ``LIMIT`` decides MEMBERSHIP, not merely
1014
- order, so a row excluded by a textual comparison of two spellings of one
1015
- instant cannot be recovered by any later projection. SQLite parses the
1016
- timezone indicator, so for every aware spelling this returns the same
1017
- canonical UTC ``Z`` string the Python helper returns —
1018
- ``tests/test_556_s3_alert_ordering.py`` pins that agreement over the
1019
- committed estate. The twins diverge on exactly one input: SQLite reads a
1020
- naive value as UTC where the Python helper raises, so such a row is ordered
1021
- here and rejected later. An unparseable value yields SQL ``NULL``, which
1022
- sorts last under ``DESC``, so the ``LIMIT`` usually drops that row before
1023
- composition can reject it — corruption is truncated away, not surfaced.
1124
+ """The SQL twin of :func:`canonical_alerted_at`, for the same spelling.
1125
+
1126
+ #556 S3 §2.3 / #869 F14. It agrees with the Python helper for every aware
1127
+ spelling, including a sub-second one, and is kept so a writer emitting a
1128
+ spelling only one side understands surfaces in the agreement test. It is
1129
+ variable-width, so it never orders a ``LIMIT``; that is
1130
+ :func:`alerted_at_order_key_sql`'s job.
1024
1131
  """
1025
- return f"strftime('%Y-%m-%dT%H:%M:%SZ', {column})"
1132
+ fraction = _alerted_at_fraction_sql(column)
1133
+ return (
1134
+ f"(CASE WHEN {fraction} = '000000' "
1135
+ f"THEN strftime('%Y-%m-%dT%H:%M:%SZ', {column}) "
1136
+ f"ELSE strftime('%Y-%m-%dT%H:%M:%S', {column}) || '.' || {fraction} "
1137
+ f"|| 'Z' END)"
1138
+ )
1026
1139
 
1027
1140
 
1028
1141
  def _leg_period(
@@ -1525,7 +1638,7 @@ def _combined_alert_rows(
1525
1638
 
1526
1639
  def _instant(row: Mapping[str, object]) -> str:
1527
1640
  try:
1528
- return canonical_alerted_at(row.get("alerted_at"))
1641
+ return alerted_at_order_key(row.get("alerted_at"))
1529
1642
  except ValueError as exc:
1530
1643
  identity = row.get("id") if row.get("id") is not None else row.get("key")
1531
1644
  raise ValueError(
@@ -1827,10 +1940,116 @@ _CLAUDE_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
1827
1940
  )
1828
1941
 
1829
1942
 
1943
+ _STATS_RELATIONS_DIGEST_MEMO: "dict[tuple, str]" = {}
1944
+
1945
+ # A file timestamp is only as fine as its filesystem: nanoseconds on APFS, a
1946
+ # kernel tick on ext4, one second on HFS+, two on FAT. Two commits inside one
1947
+ # tick can leave identical timestamps, so no digest is memoized until the
1948
+ # file's newest timestamp is at least this old. Any later commit then carries a
1949
+ # strictly newer timestamp, whatever the granularity up to this bound.
1950
+ _STATS_DIGEST_MEMO_SETTLE_SECONDS = 2.0
1951
+
1952
+
1953
+ def _stats_memo_now() -> float:
1954
+ """The wall clock the settle window is measured on (a test seam)."""
1955
+ return time.time()
1956
+
1957
+
1958
+ def _stats_relations_digest_signal(
1959
+ stats_conn: sqlite3.Connection,
1960
+ ) -> "tuple | None":
1961
+ """Return the stats file's write identity, or no cache key.
1962
+
1963
+ The journal cursor cannot serve as this identity. ``_run_cycle`` runs the
1964
+ Codex projection leg and the budget-config reconcile on a cycle that
1965
+ consumed no journal bytes and rewrites the cursor to the same high-water
1966
+ mark, and the own-connection reconcile branches, the incomplete-projection
1967
+ reconciliation and every administrative writer never touch it at all.
1968
+
1969
+ Every committed SQLite write in rollback-journal mode rewrites pages of the
1970
+ main file, which moves its mtime and ctime, so the file's ``stat`` covers
1971
+ all of those writers, in every process, without enumerating them. SQLite
1972
+ skips an overwrite whose bytes are unchanged, so an idle cycle that
1973
+ rewrites the cursor and selector rows to identical values leaves the file,
1974
+ and this key, untouched. ``(st_dev, st_ino)`` covers a rebuilt or replaced
1975
+ index, and ``st_size`` rides along for free.
1976
+
1977
+ The file is only ever ``stat``-ed, never opened: closing a second
1978
+ descriptor on a database file releases every POSIX lock this process's
1979
+ SQLite connections hold on it.
1980
+
1981
+ A connection inside a transaction may be reading its own uncommitted
1982
+ writes, which the file does not show yet, so it stays on the cold path, as
1983
+ does an in-memory or temporary store.
1984
+ """
1985
+ if stats_conn.in_transaction:
1986
+ return None
1987
+ try:
1988
+ rows = stats_conn.execute("PRAGMA database_list").fetchall()
1989
+ path = next(
1990
+ str(row[2]) for row in rows
1991
+ if len(row) > 2 and str(row[1]) == "main" and row[2]
1992
+ )
1993
+ stat = os.stat(path)
1994
+ except (OSError, StopIteration, sqlite3.Error, TypeError, ValueError):
1995
+ return None
1996
+ return (
1997
+ path, stat.st_dev, stat.st_ino, stat.st_size,
1998
+ stat.st_mtime_ns, stat.st_ctime_ns,
1999
+ )
2000
+
2001
+
2002
+ def _stats_relations_digest_memoizable(
2003
+ stats_conn: sqlite3.Connection,
2004
+ signal: tuple,
2005
+ observed_at: float,
2006
+ ) -> bool:
2007
+ """May a digest computed under ``signal`` be served to a later read?
2008
+
2009
+ Only when the file did not move while the relations were read, the file had
2010
+ settled before they were, and the store is not in WAL mode. A WAL commit
2011
+ lands in the ``-wal`` file and leaves the main file's timestamps alone, so
2012
+ the key could not see it. The mode is read here, after the relation reads,
2013
+ because a connection reports ``wal`` only once it has read the file. A later
2014
+ switch into WAL is itself a write to the main file, so a stored entry never
2015
+ has to be re-checked.
2016
+ """
2017
+ newest = max(signal[4], signal[5]) / 1e9
2018
+ if observed_at - newest < _STATS_DIGEST_MEMO_SETTLE_SECONDS:
2019
+ return False
2020
+ try:
2021
+ if stats_conn.in_transaction:
2022
+ return False
2023
+ mode = stats_conn.execute("PRAGMA journal_mode").fetchone()
2024
+ if mode is None or str(mode[0]).lower() == "wal":
2025
+ return False
2026
+ stat = os.stat(signal[0])
2027
+ except (OSError, sqlite3.Error, TypeError, ValueError):
2028
+ return False
2029
+ return signal[1:] == (
2030
+ stat.st_dev, stat.st_ino, stat.st_size,
2031
+ stat.st_mtime_ns, stat.st_ctime_ns,
2032
+ )
2033
+
2034
+
1830
2035
  def _stats_relations_digest(
1831
2036
  stats_conn: sqlite3.Connection,
1832
2037
  relations: tuple[tuple[str, str], ...],
2038
+ *,
2039
+ memo_namespace: "str | None" = None,
1833
2040
  ) -> str:
2041
+ signal = None
2042
+ observed_at = 0.0
2043
+ if memo_namespace is not None:
2044
+ # The clock is read BEFORE the file, so the settle test compares the
2045
+ # file against an instant no later than the relation reads.
2046
+ observed_at = _stats_memo_now()
2047
+ signal = _stats_relations_digest_signal(stats_conn)
2048
+ key = (memo_namespace, signal) if signal is not None else None
2049
+ if key is not None:
2050
+ cached = _STATS_RELATIONS_DIGEST_MEMO.get(key)
2051
+ if cached is not None:
2052
+ return cached
1834
2053
  relation_rows: list[list[list[object]]] = []
1835
2054
  for _name, query in relations:
1836
2055
  try:
@@ -1846,7 +2065,15 @@ def _stats_relations_digest(
1846
2065
  ensure_ascii=False,
1847
2066
  separators=(",", ":"),
1848
2067
  ).encode("utf-8")
1849
- return hashlib.sha256(canonical).hexdigest()
2068
+ digest = hashlib.sha256(canonical).hexdigest()
2069
+ if key is not None and _stats_relations_digest_memoizable(
2070
+ stats_conn, signal, observed_at,
2071
+ ):
2072
+ for prior in tuple(_STATS_RELATIONS_DIGEST_MEMO):
2073
+ if prior[0] == memo_namespace:
2074
+ _STATS_RELATIONS_DIGEST_MEMO.pop(prior, None)
2075
+ _STATS_RELATIONS_DIGEST_MEMO[key] = digest
2076
+ return digest
1850
2077
 
1851
2078
 
1852
2079
  def claude_stats_digest(stats_conn: sqlite3.Connection) -> str:
@@ -1855,7 +2082,11 @@ def claude_stats_digest(stats_conn: sqlite3.Connection) -> str:
1855
2082
  A missing table is an empty relation, so an older or fresh stats database
1856
2083
  still has a stable digest — the same posture ``codex_stats_digest`` takes.
1857
2084
  """
1858
- return _stats_relations_digest(stats_conn, _CLAUDE_STATS_DIGEST_RELATIONS)
2085
+ return _stats_relations_digest(
2086
+ stats_conn,
2087
+ _CLAUDE_STATS_DIGEST_RELATIONS,
2088
+ memo_namespace="claude",
2089
+ )
1859
2090
 
1860
2091
 
1861
2092
  def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
@@ -1866,7 +2097,11 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
1866
2097
  then follows the source all-or-prior failure matrix instead of publishing a
1867
2098
  guessed identity.
1868
2099
  """
1869
- return _stats_relations_digest(stats_conn, _CODEX_STATS_DIGEST_RELATIONS)
2100
+ return _stats_relations_digest(
2101
+ stats_conn,
2102
+ _CODEX_STATS_DIGEST_RELATIONS,
2103
+ memo_namespace="codex",
2104
+ )
1870
2105
 
1871
2106
 
1872
2107
  def assess_codex_projection_coherence(
@@ -1011,7 +1011,7 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
1011
1011
  # current index as a mismatch; keep this in lockstep with core
1012
1012
  # (#496 S5b §6.1). It stays a literal because this kernel is pure and
1013
1013
  # must not import `_cctally_core`.
1014
- epoch = 1015
1014
+ epoch = 1016
1015
1015
  mismatch = uv > legacy_head and uv != epoch
1016
1016
  return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
1017
1017
  "mismatch": mismatch}
@@ -3528,7 +3528,7 @@ def _check_journal_writer_guard(s: DoctorState) -> CheckResult:
3528
3528
  # not promise one it cannot deliver.
3529
3529
  _REDERIVABLE_CONFLICT_PREFIXES = (
3530
3530
  "sa:", "wcs:", "wce:", "wr:", "fhc:", "fhbc:", "pm:", "fhm:",
3531
- "bm:", "pjm:", "pbm:",
3531
+ "bm:", "bm2:", "pjm:", "pjm2:", "pbm:", "pbm2:",
3532
3532
  )
3533
3533
 
3534
3534
 
@@ -1456,33 +1456,125 @@ def _provider_complete(conn, provider: str) -> bool:
1456
1456
  return row is not None
1457
1457
 
1458
1458
 
1459
- def _source_paths(conn, provider: str) -> tuple[pathlib.Path, ...]:
1459
+ def _source_paths(conn, provider: str) -> tuple[str, ...]:
1460
+ """Every absolute accounting source path, as the stored string.
1461
+
1462
+ Strings rather than ``pathlib.Path`` objects: ``_source_directory_closure``
1463
+ resolves each distinct parent directory once, so building a path object
1464
+ per source row here was pure overhead on a large store (#880).
1465
+ """
1460
1466
  table = _ingest_source_table(provider)
1461
1467
  return tuple(
1462
- pathlib.Path(str(row[0]))
1468
+ str(row[0])
1463
1469
  for row in conn.execute(f"SELECT path FROM {table}")
1464
1470
  if row[0] and os.path.isabs(str(row[0]))
1465
1471
  )
1466
1472
 
1467
1473
 
1468
- def _directory_paths(conn, provider: str, roots) -> tuple[pathlib.Path, ...]:
1474
+ def _is_canonical_absolute(raw: str) -> bool:
1475
+ """Whether ``str(pathlib.Path(raw)) == raw`` for an absolute ``raw``.
1476
+
1477
+ POSIX pathlib drops empty and ``.`` segments and a trailing separator, and
1478
+ keeps a leading ``//`` as a distinct anchor. A string with none of those
1479
+ spellings is already in pathlib's form, so its parent is the text before
1480
+ its last separator. ``/`` itself is excluded so that every string this
1481
+ accepts has a non-empty final segment.
1482
+ """
1483
+ return (
1484
+ raw.startswith("/")
1485
+ and "//" not in raw
1486
+ and "/./" not in raw
1487
+ and not raw.endswith("/")
1488
+ and not raw.endswith("/.")
1489
+ )
1490
+
1491
+
1492
+ def _add_source_chain_pathlib(raw: str, normalized_roots, directories) -> None:
1493
+ """The original per-source pathlib derivation, for a non-canonical string.
1494
+
1495
+ Adds the parent of ``raw`` and every directory above it, up to and
1496
+ including the FIRST root in ``normalized_roots`` order that lexically
1497
+ contains it. A parent outside every root contributes nothing.
1498
+ """
1499
+ parent = pathlib.Path(raw).parent
1500
+ for root in normalized_roots:
1501
+ try:
1502
+ parent.relative_to(root)
1503
+ except ValueError:
1504
+ continue
1505
+ cur = parent
1506
+ while True:
1507
+ directories.add(str(cur))
1508
+ if cur == root:
1509
+ break
1510
+ cur = cur.parent
1511
+ break
1512
+
1513
+
1514
+ def _source_directory_closure(read_sources, roots) -> tuple[pathlib.Path, ...]:
1515
+ """The directory set a walk certifies: the roots, plus each source's chain.
1516
+
1517
+ A source contributes its parent and every directory above it up to the
1518
+ FIRST configured root, in the caller's order, that lexically contains the
1519
+ parent; a source outside every root contributes nothing. That is exactly
1520
+ the per-source ``pathlib`` derivation this replaced, and it returns the
1521
+ same sorted tuple of ``pathlib.Path`` objects.
1522
+
1523
+ What changed is the cost (#880). Thousands of source rows share a few
1524
+ thousand parent directories, and the old loop built a path object per row,
1525
+ tested it against each root with ``relative_to``, and re-walked an
1526
+ ancestor chain its siblings had already collected. Here a canonical string
1527
+ is handled as text: each distinct parent is resolved once, and a chain
1528
+ walk stops at the first directory whose chain up to the SAME root is
1529
+ already collected. The memo is keyed by root, not shared, because with
1530
+ nested roots a directory can be complete up to an inner root while the
1531
+ directories between that inner root and an outer one are not — stopping
1532
+ at "already in the set" would lose them whenever the outer root comes
1533
+ first. A non-canonical string (``//``, ``/./``, a trailing separator)
1534
+ falls back to the original pathlib computation for that one row.
1535
+
1536
+ ``read_sources`` is called only after the roots are resolved, the order
1537
+ the pathlib derivation used, so an unresolvable root fails as the OSError
1538
+ the seed path treats as a non-certifiable walk rather than behind a store
1539
+ error.
1540
+ """
1469
1541
  normalized_roots = tuple(pathlib.Path(root).absolute() for root in roots)
1470
- directories = set(normalized_roots)
1471
- for source in _source_paths(conn, provider):
1472
- parent = source.parent
1473
- for root in normalized_roots:
1474
- try:
1475
- parent.relative_to(root)
1476
- except ValueError:
1542
+ root_strings = tuple(str(root) for root in normalized_roots)
1543
+ # A canonical parent strictly below root `r` starts with `r + "/"`; the
1544
+ # filesystem root is already its own prefix. A root anchored at `//` can
1545
+ # contain no canonical parent, and none of its prefixes match one.
1546
+ root_prefixes = tuple(
1547
+ root if root.endswith("/") else root + "/" for root in root_strings)
1548
+ directories = set(root_strings)
1549
+ # complete[i]: directories whose whole chain up to root i is collected.
1550
+ complete = tuple({root} for root in root_strings)
1551
+ resolved_parents = set()
1552
+ for raw in read_sources():
1553
+ if not _is_canonical_absolute(raw):
1554
+ _add_source_chain_pathlib(raw, normalized_roots, directories)
1555
+ continue
1556
+ parent = raw[:raw.rfind("/")] or "/"
1557
+ if parent in resolved_parents:
1558
+ continue
1559
+ resolved_parents.add(parent)
1560
+ for index, prefix in enumerate(root_prefixes):
1561
+ if parent != root_strings[index] and not parent.startswith(prefix):
1477
1562
  continue
1563
+ chain = complete[index]
1478
1564
  cur = parent
1479
- while True:
1565
+ # Terminates: `cur` lies strictly below root i until it becomes
1566
+ # root i, which is in `chain` from the start.
1567
+ while cur not in chain:
1568
+ chain.add(cur)
1480
1569
  directories.add(cur)
1481
- if cur == root:
1482
- break
1483
- cur = cur.parent
1570
+ cur = cur[:cur.rfind("/")] or "/"
1484
1571
  break
1485
- return tuple(sorted(directories, key=str))
1572
+ return tuple(pathlib.Path(directory) for directory in sorted(directories))
1573
+
1574
+
1575
+ def _directory_paths(conn, provider: str, roots) -> tuple[pathlib.Path, ...]:
1576
+ return _source_directory_closure(
1577
+ lambda: _source_paths(conn, provider), roots)
1486
1578
 
1487
1579
 
1488
1580
  def _directory_identity(conn, provider: str, roots):
@@ -1493,10 +1585,28 @@ def _directory_identity(conn, provider: str, roots):
1493
1585
 
1494
1586
 
1495
1587
  def _restat_directory_identity(saved):
1496
- return {
1497
- raw: _stat_identity_or_missing(pathlib.Path(raw))
1498
- for raw in saved
1499
- }
1588
+ """Restat every saved directory key; ``(0, 0, 0, 0)`` when it is gone.
1589
+
1590
+ Both frontiers run this on every idle tick over thousands of keys. The
1591
+ keys are ``str(path)`` of the paths the walk stated, already in pathlib's
1592
+ normalized form, so ``os.stat`` on the key stats exactly what
1593
+ ``pathlib.Path(key).stat()`` would — without building a path object per
1594
+ key per tick, which cost about three times the stat itself (#880).
1595
+
1596
+ That equivalence holds for exactly those keys: non-empty ``str`` values
1597
+ with no trailing separator. An empty key, a trailing slash, bytes or an
1598
+ integer would behave differently, and the walk never records one.
1599
+ """
1600
+ identity = {}
1601
+ for raw in saved:
1602
+ try:
1603
+ st = os.stat(raw)
1604
+ except OSError:
1605
+ identity[raw] = (0, 0, 0, 0)
1606
+ else:
1607
+ identity[raw] = (
1608
+ st.st_dev, st.st_ino, st.st_mtime_ns, st.st_ctime_ns)
1609
+ return identity
1500
1610
 
1501
1611
 
1502
1612
  def _guard_identity(paths):
@@ -1935,33 +2045,22 @@ def _conversation_source_table(provider: str) -> str:
1935
2045
  raise ValueError("unknown provider")
1936
2046
 
1937
2047
 
1938
- def _conversation_source_paths(conn, provider: str) -> tuple[pathlib.Path, ...]:
2048
+ def _conversation_source_paths(conn, provider: str) -> tuple[str, ...]:
2049
+ """Every absolute transcript source path, as the stored string.
2050
+
2051
+ The transcript twin of ``_source_paths``, and a string for the same reason.
2052
+ """
1939
2053
  table = _conversation_source_table(provider)
1940
2054
  return tuple(
1941
- pathlib.Path(str(row[0]))
2055
+ str(row[0])
1942
2056
  for row in conn.execute(f"SELECT path FROM {table}")
1943
2057
  if row[0] and os.path.isabs(str(row[0]))
1944
2058
  )
1945
2059
 
1946
2060
 
1947
2061
  def _conversation_directory_paths(conn, provider: str, roots):
1948
- normalized_roots = tuple(pathlib.Path(root).absolute() for root in roots)
1949
- directories = set(normalized_roots)
1950
- for source in _conversation_source_paths(conn, provider):
1951
- parent = source.parent
1952
- for root in normalized_roots:
1953
- try:
1954
- parent.relative_to(root)
1955
- except ValueError:
1956
- continue
1957
- cur = parent
1958
- while True:
1959
- directories.add(cur)
1960
- if cur == root:
1961
- break
1962
- cur = cur.parent
1963
- break
1964
- return tuple(sorted(directories, key=str))
2062
+ return _source_directory_closure(
2063
+ lambda: _conversation_source_paths(conn, provider), roots)
1965
2064
 
1966
2065
 
1967
2066
  def _conversation_directory_identity(conn, provider: str, roots):
@@ -63,7 +63,7 @@ def _chip_for_model(name: str) -> str:
63
63
  # fingerprint a store recorded and refuses a write from an older process. That
64
64
  # comparison is day-granular by construction, so two revisions sharing a date
65
65
  # compare equal and the older process is authorized to write.
66
- PRICING_SNAPSHOT_DATE = "2026-09-23"
66
+ PRICING_SNAPSHOT_DATE = "2026-09-30"
67
67
  PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
68
68
 
69
69
 
@@ -400,6 +400,11 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
400
400
  # MTok, $5 five-minute cache write, $0.20 cache read, and 2x fast rate.
401
401
  # Its 1M context uses standard rates throughout. The pricing fingerprint
402
402
  # advances to 2026-09-23 because another revision already used 2026-09-22.
403
+ # 2026-09-29: added claude-sonnet-5-5 at Anthropic's published $2/$10 per
404
+ # MTok, $2.50 five-minute cache write and $0.20 cache read, the same card
405
+ # as Sonnet 5 and the standard 0.1x read multiplier. 1M context at standard
406
+ # pricing, so NO above-200k tier, and no fast mode, so no fast multiplier.
407
+ # Verified against LiteLLM's anthropic-provider row on 2026-09-29.
403
408
  # Anthropic prices a cache WRITE by TTL: 1.25x base input for a 5-minute write,
404
409
  # 2x for a 1-hour write. Both WRITE multipliers are documented as applying
405
410
  # consistently across all supported models, so the 1h rate is DERIVED from
@@ -646,6 +651,13 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
646
651
  "cache_creation_input_token_cost": 2.5e-06,
647
652
  "cache_read_input_token_cost": 2e-07,
648
653
  },
654
+ "claude-sonnet-5-5": {
655
+ # Source: https://platform.claude.com/docs/en/models/sonnet-5-5/overview
656
+ "input_cost_per_token": 2e-06,
657
+ "output_cost_per_token": 1e-05,
658
+ "cache_creation_input_token_cost": 2.5e-06,
659
+ "cache_read_input_token_cost": 2e-07,
660
+ },
649
661
  }
650
662
 
651
663
  # Anthropic fast-mode pricing is genuinely model-specific. Unsupported models
@@ -750,6 +762,11 @@ _unknown_model_warnings: set[str] = set()
750
762
  # card at $12.50 input / $1.25 cached input / $75.00 output per MTok.
751
763
  # The current OpenAI table lists its 5.6 successor instead. LiteLLM does
752
764
  # not publish an above-272K rate for 5.5 Cyber, so do not infer that tier.
765
+ # 2026-09-29: added gpt-6.1-sol from OpenAI's model and pricing pages:
766
+ # Standard $2.00 input / $0.10 cached input / $10.00 output per MTok;
767
+ # above 272K, $4.00 / $0.20 / $15.00. Fast mode is 2x. LiteLLM does not
768
+ # yet carry this identifier. The snapshot date advances to 2026-09-30
769
+ # because the Sonnet 5.5 sync already used 2026-09-29 (#705).
753
770
  #
754
771
  # Billing rules:
755
772
  # - reasoning_output_tokens is billed at the *output* rate (matches
@@ -915,6 +932,17 @@ CODEX_MODEL_PRICING: dict[str, dict[str, Any]] = {
915
932
  "cache_read_input_token_cost_above_272k_tokens": 4e-07,
916
933
  "output_cost_per_token_above_272k_tokens": 1.5e-05,
917
934
  },
935
+ "gpt-6.1-sol": {
936
+ # Source: https://developers.openai.com/api/docs/models/gpt-6.1-sol
937
+ # Standard $2.00/M input, $0.10/M cached input, $10.00/M output;
938
+ # above 272K input, rates are 2x input/cache and 1.5x output.
939
+ "input_cost_per_token": 2e-06,
940
+ "cache_read_input_token_cost": 1e-07,
941
+ "output_cost_per_token": 1e-05,
942
+ "input_cost_per_token_above_272k_tokens": 4e-06,
943
+ "cache_read_input_token_cost_above_272k_tokens": 2e-07,
944
+ "output_cost_per_token_above_272k_tokens": 1.5e-05,
945
+ },
918
946
  "gpt-6-luna": {
919
947
  # Source: https://developers.openai.com/api/docs/models/gpt-6-luna
920
948
  "input_cost_per_token": 1e-07,