cctally 1.86.0 → 1.87.1
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.
- package/CHANGELOG.md +10 -0
- package/bin/_cctally_cache.py +252 -0
- package/bin/_cctally_dashboard_envelope.py +33 -11
- package/bin/_cctally_db.py +50 -0
- package/bin/_cctally_quota.py +72 -3
- package/bin/_lib_codex_account_adoption.py +173 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.87.1] - 2026-07-30
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Codex session names now remain visible in Recent Sessions when an individual account is selected, whenever transcript visibility is enabled. Selecting an account no longer replaces every session name with an em dash; disabling transcript visibility still hides names in both the all-accounts and focused-account views.
|
|
12
|
+
|
|
13
|
+
## [1.87.0] - 2026-07-30
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- A Codex cycle's per-percent milestone ladder no longer reports `$0.00` for a crossing whose spend is real. cctally already recognised which account a quota window belongs to even when individual samples arrived unlabelled, but the dollars behind those percentages were attributed separately, per rollout file — and for usage recorded before cctally began keeping durable per-file attribution there was no decision to apply, so that spend belonged to nobody. The crossing was confidently the account's while its cost read as zero, the cycle hero under-reported spend by the same amount, and roughly a seventh of one Pro account's weekly Codex spend sat in the `unattributed` bucket instead of on its own card. Codex spend recorded inside an account-level weekly window is now attributed to that window's account whenever the window names exactly one, so the ladder, the cycle hero, the per-account cards, the Codex budget and the CLI all agree. Nothing is guessed: spend that no window can name is left alone, so is spend claimed by two windows naming different accounts, and separate model pools such as GPT-5.3-Codex-Spark are never treated as account quota. Existing history is repaired once on upgrade, and no usage is ever counted twice.
|
|
17
|
+
|
|
8
18
|
## [1.86.0] - 2026-07-30
|
|
9
19
|
|
|
10
20
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -192,6 +192,11 @@ _iter_message_rows = _lib_conversation.iter_message_rows
|
|
|
192
192
|
# so it loads at module-load time alongside _lib_conversation.
|
|
193
193
|
_lib_codex_conversation = _load_lib("_lib_codex_conversation")
|
|
194
194
|
|
|
195
|
+
# Window-scoped spend adoption's decision kernel (2026-07-30 spec). Pure
|
|
196
|
+
# stdlib leaf, same shape as `_lib_codex_pools`, so it loads here rather than
|
|
197
|
+
# through a bare import that would depend on ``bin/`` being on ``sys.path``.
|
|
198
|
+
_lib_codex_account_adoption = _load_lib("_lib_codex_account_adoption")
|
|
199
|
+
|
|
195
200
|
# Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
|
|
196
201
|
# stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
|
|
197
202
|
# shared no-op singleton), so the sync_cache seam wraps below cost nothing on
|
|
@@ -4812,6 +4817,22 @@ def _progress_codex_stderr(stats: CodexIngestStats, *, force: bool = False) -> N
|
|
|
4812
4817
|
)
|
|
4813
4818
|
|
|
4814
4819
|
|
|
4820
|
+
def _extend_codex_touched_span(
|
|
4821
|
+
spans: "dict[str, tuple[dt.datetime, dt.datetime]]",
|
|
4822
|
+
source_root_key: object,
|
|
4823
|
+
moment: "dt.datetime | None",
|
|
4824
|
+
) -> None:
|
|
4825
|
+
"""Widen one root's touched instant span in place."""
|
|
4826
|
+
if not source_root_key or moment is None:
|
|
4827
|
+
return
|
|
4828
|
+
key = str(source_root_key)
|
|
4829
|
+
current = spans.get(key)
|
|
4830
|
+
if current is None:
|
|
4831
|
+
spans[key] = (moment, moment)
|
|
4832
|
+
else:
|
|
4833
|
+
spans[key] = (min(current[0], moment), max(current[1], moment))
|
|
4834
|
+
|
|
4835
|
+
|
|
4815
4836
|
def sync_codex_cache(
|
|
4816
4837
|
conn: sqlite3.Connection,
|
|
4817
4838
|
*,
|
|
@@ -4836,6 +4857,13 @@ def sync_codex_cache(
|
|
|
4836
4857
|
"""
|
|
4837
4858
|
stats = CodexIngestStats()
|
|
4838
4859
|
project_after_unlock = False
|
|
4860
|
+
# Per-root instant span this sync wrote — accounting-row timestamps AND
|
|
4861
|
+
# canonical window resets. It bounds the end-of-sync spend-adoption pass to
|
|
4862
|
+
# the windows this sync could have changed; an unchanged tree leaves it empty
|
|
4863
|
+
# and the pass does no SQL at all. A rebuild deliberately passes ``None``
|
|
4864
|
+
# instead (full re-derivation restores the unattributed state, so the repair
|
|
4865
|
+
# has to re-run over everything).
|
|
4866
|
+
adoption_spans: "dict[str, tuple[dt.datetime, dt.datetime]]" = {}
|
|
4839
4867
|
# #313 P1 review (F4/F1): when the CACHE certificate is current we cannot
|
|
4840
4868
|
# yet decide whether to skip the reconcile — reconcile's own short-circuit
|
|
4841
4869
|
# ALSO requires the stats-side quota_projection_state signatures to match
|
|
@@ -5646,6 +5674,23 @@ def sync_codex_cache(
|
|
|
5646
5674
|
continue
|
|
5647
5675
|
anchor_resolver.mark_file_committed()
|
|
5648
5676
|
|
|
5677
|
+
if not rebuild:
|
|
5678
|
+
# Accounting timestamps share one producer spelling, so the
|
|
5679
|
+
# lexicographic extremes ARE the chronological ones and only two
|
|
5680
|
+
# rows need parsing. Quota anchors are few per file, so they are
|
|
5681
|
+
# parsed individually.
|
|
5682
|
+
if accounting_rows:
|
|
5683
|
+
for _extreme in (
|
|
5684
|
+
min(_r[2] for _r in accounting_rows),
|
|
5685
|
+
max(_r[2] for _r in accounting_rows),
|
|
5686
|
+
):
|
|
5687
|
+
_extend_codex_touched_span(
|
|
5688
|
+
adoption_spans, discovered.source_root_key,
|
|
5689
|
+
_parse_anchor_iso(_extreme))
|
|
5690
|
+
for _qrow in quota_rows:
|
|
5691
|
+
_extend_codex_touched_span(
|
|
5692
|
+
adoption_spans, _qrow[1], _parse_anchor_iso(_qrow[17]))
|
|
5693
|
+
|
|
5649
5694
|
# Private test seam (§5.1 post-preflight late-shrink race): fires
|
|
5650
5695
|
# after each file's successful commit, so a race test can shrink a
|
|
5651
5696
|
# not-yet-written target and assert the earlier commit stands.
|
|
@@ -5698,6 +5743,31 @@ def sync_codex_cache(
|
|
|
5698
5743
|
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
5699
5744
|
("codex_torn_auth_deferred",))
|
|
5700
5745
|
conn.commit()
|
|
5746
|
+
# Window-scoped spend adoption (spec
|
|
5747
|
+
# docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md).
|
|
5748
|
+
# Runs AFTER the walk committed and while both cache writer flocks are
|
|
5749
|
+
# still held, so the observation evidence and the accounting rows it
|
|
5750
|
+
# stamps are the same committed generation. Cache-only — no stats.db read
|
|
5751
|
+
# — so the lock-order law is untouched. A failure here is never fatal:
|
|
5752
|
+
# the stamp is fully re-derivable, so the next sync (or the migration)
|
|
5753
|
+
# repeats it.
|
|
5754
|
+
try:
|
|
5755
|
+
adopted = apply_codex_window_spend_adoption(
|
|
5756
|
+
conn, touched=None if rebuild else adoption_spans)
|
|
5757
|
+
conn.commit()
|
|
5758
|
+
# Terse, and silent on zero: a rebuild re-derives every row and so
|
|
5759
|
+
# legitimately re-stamps the same population each time, which would
|
|
5760
|
+
# otherwise read as a recurring anomaly rather than convergence.
|
|
5761
|
+
if adopted:
|
|
5762
|
+
eprint(f"[cache-sync] attributed {adopted} Codex row(s) "
|
|
5763
|
+
"from quota windows")
|
|
5764
|
+
except sqlite3.DatabaseError as exc:
|
|
5765
|
+
conn.rollback()
|
|
5766
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
5767
|
+
# Classified family corruption belongs to the shared recovery
|
|
5768
|
+
# boundary, never to a best-effort local except.
|
|
5769
|
+
raise
|
|
5770
|
+
eprint(f"[cache-sync] could not adopt Codex window spend: {exc}")
|
|
5701
5771
|
# Codex creates/extends cache.db sidecars independently of Claude's
|
|
5702
5772
|
# sync path. Harden them while both cache flocks are still held and
|
|
5703
5773
|
# after all Codex writes, before the optional checkpoint can rotate a
|
|
@@ -5794,6 +5864,188 @@ def sync_codex_cache(
|
|
|
5794
5864
|
return stats
|
|
5795
5865
|
|
|
5796
5866
|
|
|
5867
|
+
_CODEX_ACCOUNT_WEEK = dt.timedelta(
|
|
5868
|
+
minutes=_lib_codex_account_adoption.ACCOUNT_WEEKLY_WINDOW_MINUTES)
|
|
5869
|
+
|
|
5870
|
+
|
|
5871
|
+
def apply_codex_window_spend_adoption(
|
|
5872
|
+
conn: sqlite3.Connection,
|
|
5873
|
+
*,
|
|
5874
|
+
touched: "dict[str, tuple[dt.datetime, dt.datetime]] | None" = None,
|
|
5875
|
+
) -> int:
|
|
5876
|
+
"""Stamp window-derived attribution onto unattributed Codex spend.
|
|
5877
|
+
|
|
5878
|
+
The I/O half of ``_lib_codex_account_adoption``: read the folded window
|
|
5879
|
+
evidence and the candidate rows, hand both to the pure kernel, write back the
|
|
5880
|
+
plan it returns. Cache-only by construction — the window's identified
|
|
5881
|
+
accounts come from ``load_codex_quota_observations`` (which already runs
|
|
5882
|
+
``adopt_unidentified_observations``) and the nominal range is derived from
|
|
5883
|
+
the canonical reset, so no stats.db read is involved and the lock-order law
|
|
5884
|
+
is untouched. The caller owns the transaction and the commit.
|
|
5885
|
+
|
|
5886
|
+
``touched`` maps ``source_root_key`` to the ``(low, high)`` instant span this
|
|
5887
|
+
sync wrote — the timestamps of the accounting rows AND the canonical resets
|
|
5888
|
+
of the quota rows. ``None`` runs the pass over all history (``cache-sync
|
|
5889
|
+
--rebuild`` and the one-time migration); an EMPTY map is a no-op that issues
|
|
5890
|
+
NO SQL AT ALL, which is what keeps a quiescent hook tick free.
|
|
5891
|
+
|
|
5892
|
+
A bounded pass must reach the SAME verdict the unbounded one would, because
|
|
5893
|
+
the stamp is one-way (``NULL`` -> key, never back) and an incremental sync
|
|
5894
|
+
followed by a later rebuild would otherwise disagree. That needs the loaded
|
|
5895
|
+
window set to be a SUPERSET of the windows that can claim any candidate the
|
|
5896
|
+
scan offers, so the two bounds are derived together: windows are loaded for
|
|
5897
|
+
resets in ``[low - 7d, high + 7d]``, and candidates are clamped to
|
|
5898
|
+
``[low - 7d, high]``. Every window claiming an instant ``t`` in that
|
|
5899
|
+
candidate span has its reset in ``(t, t + 7d]``, which the window bound
|
|
5900
|
+
contains — so no window can claim a scanned row unseen. The candidate span
|
|
5901
|
+
still covers everything this sync could have changed: the rows it wrote lie
|
|
5902
|
+
in ``[low, high]``, and a window whose reset it wrote lies in ``[low, high]``
|
|
5903
|
+
too, so that window's whole nominal range lies in ``[low - 7d, high)``.
|
|
5904
|
+
|
|
5905
|
+
Idempotent and re-runnable: ``codex_session_entries`` is fully re-derived on
|
|
5906
|
+
every rebuild, so the pass must re-stamp afterwards, and re-running over an
|
|
5907
|
+
already-stamped cache writes nothing because an identified row is never a
|
|
5908
|
+
candidate. Returns the number of rows actually stamped.
|
|
5909
|
+
"""
|
|
5910
|
+
roots: "set[str] | None" = None
|
|
5911
|
+
reset_bounds: "tuple[dt.datetime, dt.datetime] | None" = None
|
|
5912
|
+
candidate_bounds: "tuple[dt.datetime, dt.datetime] | None" = None
|
|
5913
|
+
if touched is not None:
|
|
5914
|
+
spans = {
|
|
5915
|
+
str(root): span for root, span in touched.items()
|
|
5916
|
+
if root and span is not None
|
|
5917
|
+
}
|
|
5918
|
+
# Before any SQL: an unchanged tree must cost this pass nothing.
|
|
5919
|
+
if not spans:
|
|
5920
|
+
return 0
|
|
5921
|
+
roots = set(spans)
|
|
5922
|
+
low = min(span[0] for span in spans.values())
|
|
5923
|
+
high = max(span[1] for span in spans.values())
|
|
5924
|
+
reset_bounds = (low - _CODEX_ACCOUNT_WEEK, high + _CODEX_ACCOUNT_WEEK)
|
|
5925
|
+
candidate_bounds = (low - _CODEX_ACCOUNT_WEEK, high)
|
|
5926
|
+
|
|
5927
|
+
from _cctally_quota import load_codex_quota_observations
|
|
5928
|
+
|
|
5929
|
+
# `_load_lib`, not a bare import: this module is loadable in isolation, where
|
|
5930
|
+
# `bin/` may not be on `sys.path` (see the module docstring).
|
|
5931
|
+
_lib_accounts = _load_lib("_lib_accounts")
|
|
5932
|
+
is_model_scoped_codex_quota = _load_lib(
|
|
5933
|
+
"_lib_codex_pools").is_model_scoped_codex_quota
|
|
5934
|
+
adopt = _lib_codex_account_adoption
|
|
5935
|
+
try:
|
|
5936
|
+
columns = {
|
|
5937
|
+
str(row[1]) for row in conn.execute(
|
|
5938
|
+
"PRAGMA table_info(codex_session_entries)")
|
|
5939
|
+
}
|
|
5940
|
+
except sqlite3.DatabaseError:
|
|
5941
|
+
return 0
|
|
5942
|
+
if not {"account_key", "source_root_key", "timestamp_utc"} <= columns:
|
|
5943
|
+
return 0
|
|
5944
|
+
|
|
5945
|
+
try:
|
|
5946
|
+
observations = load_codex_quota_observations(
|
|
5947
|
+
source_root_keys=roots, cache_conn=conn,
|
|
5948
|
+
canonical_resets_between=reset_bounds,
|
|
5949
|
+
)
|
|
5950
|
+
except sqlite3.DatabaseError:
|
|
5951
|
+
return 0
|
|
5952
|
+
|
|
5953
|
+
# Group on the SAME key the observation fold groups on
|
|
5954
|
+
# (`_lib_quota._physical_window_key`) — the account is deliberately excluded
|
|
5955
|
+
# from it, which is precisely what makes a window able to name an account for
|
|
5956
|
+
# rows that carry none.
|
|
5957
|
+
buckets: "dict[tuple, dict]" = {}
|
|
5958
|
+
for observation in observations:
|
|
5959
|
+
identity = observation.identity
|
|
5960
|
+
bucket = buckets.get(key := _lib_quota._physical_window_key(observation))
|
|
5961
|
+
if bucket is None:
|
|
5962
|
+
bucket = buckets[key] = {
|
|
5963
|
+
"root": identity.source_root_key,
|
|
5964
|
+
"minutes": identity.window_minutes,
|
|
5965
|
+
"reset": observation.canonical_resets_at,
|
|
5966
|
+
"accounts": set(),
|
|
5967
|
+
"model_scoped": False,
|
|
5968
|
+
}
|
|
5969
|
+
if identity.account_key != _lib_accounts.UNATTRIBUTED:
|
|
5970
|
+
bucket["accounts"].add(identity.account_key)
|
|
5971
|
+
# `limit_name` is compare=False on the identity, so the label can differ
|
|
5972
|
+
# across one group's observations; ANY Spark evidence demotes the whole
|
|
5973
|
+
# window out of account weekly quota (#373). That direction only ever
|
|
5974
|
+
# withholds a stamp, never invents one.
|
|
5975
|
+
if is_model_scoped_codex_quota(
|
|
5976
|
+
identity.logical_limit_key, identity.limit_name):
|
|
5977
|
+
bucket["model_scoped"] = True
|
|
5978
|
+
|
|
5979
|
+
windows: "list[object]" = []
|
|
5980
|
+
root_ranges: "dict[str, list[tuple[dt.datetime, dt.datetime]]]" = {}
|
|
5981
|
+
for bucket in buckets.values():
|
|
5982
|
+
window = adopt.SpendAdoptionWindow(
|
|
5983
|
+
source_root_key=bucket["root"],
|
|
5984
|
+
window_minutes=bucket["minutes"],
|
|
5985
|
+
canonical_resets_at=bucket["reset"],
|
|
5986
|
+
identified_accounts=frozenset(bucket["accounts"]),
|
|
5987
|
+
model_scoped=bucket["model_scoped"],
|
|
5988
|
+
)
|
|
5989
|
+
if not window.in_scope:
|
|
5990
|
+
continue
|
|
5991
|
+
windows.append(window)
|
|
5992
|
+
root_ranges.setdefault(window.source_root_key, []).append(
|
|
5993
|
+
(window.nominal_start_at, window.canonical_resets_at))
|
|
5994
|
+
if not windows:
|
|
5995
|
+
return 0
|
|
5996
|
+
|
|
5997
|
+
# SQL bounds the scan to a coarse per-root union of the candidate windows,
|
|
5998
|
+
# clamped to the span the loaded window set provably covers (see the
|
|
5999
|
+
# docstring); exact half-open containment stays in the kernel. `unixepoch`
|
|
6000
|
+
# deliberately accepts both retained spellings (`Z` and `+00:00`) — the
|
|
6001
|
+
# accounting rows are written with the offset form, the quota rows with `Z`.
|
|
6002
|
+
# Both comparisons are INCLUSIVE on the truncated second: `unixepoch` drops
|
|
6003
|
+
# any sub-second fraction, so an exclusive upper bound would discard rows in
|
|
6004
|
+
# the reset's final second if a canonical anchor ever carried one. Admitting
|
|
6005
|
+
# that second here is free — the kernel re-tests containment exactly.
|
|
6006
|
+
candidates = []
|
|
6007
|
+
for root, spans_for_root in root_ranges.items():
|
|
6008
|
+
window_low = min(span[0] for span in spans_for_root)
|
|
6009
|
+
window_high = max(span[1] for span in spans_for_root)
|
|
6010
|
+
if candidate_bounds is not None:
|
|
6011
|
+
window_low = max(window_low, candidate_bounds[0])
|
|
6012
|
+
window_high = min(window_high, candidate_bounds[1])
|
|
6013
|
+
if window_low > window_high:
|
|
6014
|
+
continue
|
|
6015
|
+
for row in conn.execute(
|
|
6016
|
+
"SELECT id, timestamp_utc FROM codex_session_entries "
|
|
6017
|
+
" WHERE source_root_key = ? "
|
|
6018
|
+
" AND (account_key IS NULL OR account_key = '' "
|
|
6019
|
+
" OR account_key = ?) "
|
|
6020
|
+
" AND unixepoch(timestamp_utc) >= unixepoch(?) "
|
|
6021
|
+
" AND unixepoch(timestamp_utc) <= unixepoch(?)",
|
|
6022
|
+
(root, _lib_accounts.UNATTRIBUTED,
|
|
6023
|
+
_codex_anchor_iso(window_low), _codex_anchor_iso(window_high)),
|
|
6024
|
+
):
|
|
6025
|
+
timestamp = _parse_anchor_iso(row[1])
|
|
6026
|
+
if timestamp is None:
|
|
6027
|
+
continue
|
|
6028
|
+
candidates.append(adopt.SpendAdoptionCandidate(
|
|
6029
|
+
entry_id=int(row[0]), source_root_key=root,
|
|
6030
|
+
timestamp=timestamp, account_key=None,
|
|
6031
|
+
))
|
|
6032
|
+
if not candidates:
|
|
6033
|
+
return 0
|
|
6034
|
+
|
|
6035
|
+
plan = adopt.build_spend_adoption_plan(windows, candidates)
|
|
6036
|
+
if not plan:
|
|
6037
|
+
return 0
|
|
6038
|
+
before = conn.total_changes
|
|
6039
|
+
conn.executemany(
|
|
6040
|
+
"UPDATE codex_session_entries SET account_key = ? "
|
|
6041
|
+
" WHERE id = ? AND (account_key IS NULL OR account_key = '' "
|
|
6042
|
+
" OR account_key = ?)",
|
|
6043
|
+
[(stamp.account_key, stamp.entry_id, _lib_accounts.UNATTRIBUTED)
|
|
6044
|
+
for stamp in plan],
|
|
6045
|
+
)
|
|
6046
|
+
return conn.total_changes - before
|
|
6047
|
+
|
|
6048
|
+
|
|
5797
6049
|
def iter_codex_entries(
|
|
5798
6050
|
conn: sqlite3.Connection,
|
|
5799
6051
|
range_start: dt.datetime,
|
|
@@ -1787,11 +1787,39 @@ def _overlay_claude_source_session_titles(
|
|
|
1787
1787
|
|
|
1788
1788
|
|
|
1789
1789
|
def _codex_source_session_rows(envelope: dict) -> list:
|
|
1790
|
-
"""Return
|
|
1790
|
+
"""Return every request-local Codex session row list.
|
|
1791
|
+
|
|
1792
|
+
Besides the merged parent used by ``All accounts``, decorated Codex data
|
|
1793
|
+
carries one ``account_scopes[*].sessions`` child per account. The client
|
|
1794
|
+
swaps that child into view when an account chip is focused, so the private
|
|
1795
|
+
label overlay must cover it under the same per-request transcript gate.
|
|
1796
|
+
"""
|
|
1791
1797
|
sources = envelope.get("sources")
|
|
1792
1798
|
if not isinstance(sources, Mapping):
|
|
1793
1799
|
return []
|
|
1794
1800
|
out = []
|
|
1801
|
+
|
|
1802
|
+
def append_rows(data) -> None:
|
|
1803
|
+
if not isinstance(data, Mapping):
|
|
1804
|
+
return
|
|
1805
|
+
sessions = data.get("sessions")
|
|
1806
|
+
if isinstance(sessions, Mapping):
|
|
1807
|
+
rows = sessions.get("rows")
|
|
1808
|
+
if isinstance(rows, list):
|
|
1809
|
+
out.append(rows)
|
|
1810
|
+
account_scopes = data.get("account_scopes")
|
|
1811
|
+
if not isinstance(account_scopes, Mapping):
|
|
1812
|
+
return
|
|
1813
|
+
for child in account_scopes.values():
|
|
1814
|
+
if not isinstance(child, Mapping):
|
|
1815
|
+
continue
|
|
1816
|
+
sessions = child.get("sessions")
|
|
1817
|
+
if not isinstance(sessions, Mapping):
|
|
1818
|
+
continue
|
|
1819
|
+
rows = sessions.get("rows")
|
|
1820
|
+
if isinstance(rows, list):
|
|
1821
|
+
out.append(rows)
|
|
1822
|
+
|
|
1795
1823
|
candidates = [(sources.get("codex") or {}).get("data")]
|
|
1796
1824
|
all_data = (sources.get("all") or {}).get("data")
|
|
1797
1825
|
if isinstance(all_data, Mapping):
|
|
@@ -1799,14 +1827,7 @@ def _codex_source_session_rows(envelope: dict) -> list:
|
|
|
1799
1827
|
if isinstance(providers, Mapping):
|
|
1800
1828
|
candidates.append(providers.get("codex"))
|
|
1801
1829
|
for data in candidates:
|
|
1802
|
-
|
|
1803
|
-
continue
|
|
1804
|
-
sessions = data.get("sessions")
|
|
1805
|
-
if not isinstance(sessions, Mapping):
|
|
1806
|
-
continue
|
|
1807
|
-
rows = sessions.get("rows")
|
|
1808
|
-
if isinstance(rows, list):
|
|
1809
|
-
out.append(rows)
|
|
1830
|
+
append_rows(data)
|
|
1810
1831
|
return out
|
|
1811
1832
|
|
|
1812
1833
|
|
|
@@ -1818,8 +1839,9 @@ def _overlay_codex_source_session_labels(
|
|
|
1818
1839
|
``state_5.sqlite.threads.title`` is derived from transcript prompt content.
|
|
1819
1840
|
The frozen source state therefore retains it only in a server-private key
|
|
1820
1841
|
map, outside the published ``data`` tree. Closed requests return before
|
|
1821
|
-
consulting that map; open requests match labels to
|
|
1822
|
-
their opaque resource keys. A missed call
|
|
1842
|
+
consulting that map; open requests match labels to merged-parent,
|
|
1843
|
+
account-scoped, and All rows by their opaque resource keys. A missed call
|
|
1844
|
+
fails closed.
|
|
1823
1845
|
"""
|
|
1824
1846
|
if not transcripts_visible:
|
|
1825
1847
|
return
|
package/bin/_cctally_db.py
CHANGED
|
@@ -6085,6 +6085,56 @@ def _033_codex_reset_anchor_component_closure(
|
|
|
6085
6085
|
conn.commit()
|
|
6086
6086
|
|
|
6087
6087
|
|
|
6088
|
+
@cache_migration("034_codex_window_spend_adoption")
|
|
6089
|
+
def _034_codex_window_spend_adoption(conn: sqlite3.Connection) -> None:
|
|
6090
|
+
"""One-time window-scoped spend adoption over existing Codex history.
|
|
6091
|
+
|
|
6092
|
+
Spec:
|
|
6093
|
+
``docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md``.
|
|
6094
|
+
|
|
6095
|
+
Ingest stamps every window forward from here, but rollout bytes that were
|
|
6096
|
+
already walked keep whatever per-file attribution decided for them — and for
|
|
6097
|
+
history predating the durable attribution map that is nothing at all (#416
|
|
6098
|
+
spec D1). A cycle's milestone ladder then renders ``$0.00`` for a crossing
|
|
6099
|
+
whose spend is real, because the crossing came from the folded observation
|
|
6100
|
+
axis and the dollars from the per-file axis. This handler runs the SAME pass
|
|
6101
|
+
ingest now runs, unbounded, so existing history converges in one pass.
|
|
6102
|
+
|
|
6103
|
+
The whole decision lives in ``_lib_codex_account_adoption`` and the read/write
|
|
6104
|
+
in ``_cctally_cache.apply_codex_window_spend_adoption``; re-implementing the
|
|
6105
|
+
rule here would let the migration and ingest drift.
|
|
6106
|
+
|
|
6107
|
+
Idempotent by construction: only rows whose ``account_key`` is ``NULL`` or
|
|
6108
|
+
empty are candidates, so a re-run over its own output writes nothing. NO
|
|
6109
|
+
self-stamp — the dispatcher central-stamps on a clean return (#140).
|
|
6110
|
+
|
|
6111
|
+
Takes the Codex provider flock like handlers 024-027: this writes a
|
|
6112
|
+
Codex-derived table, so a mid-walk ``sync_codex_cache`` must not interleave.
|
|
6113
|
+
On contention it DEFERS (``MigrationGateNotMet``) before touching any data —
|
|
6114
|
+
the safe side, and free here because that same sync runs the identical pass
|
|
6115
|
+
at its own end anyway.
|
|
6116
|
+
|
|
6117
|
+
``BEGIN IMMEDIATE`` before the read, like handler 024: the pass reads the
|
|
6118
|
+
folded window evidence and then writes back a plan derived from it, so it
|
|
6119
|
+
must hold the write lock for the whole read-then-write rather than upgrade
|
|
6120
|
+
part way through it.
|
|
6121
|
+
"""
|
|
6122
|
+
import _cctally_cache as cache_mod
|
|
6123
|
+
|
|
6124
|
+
held = _acquire_cache_db_codex_provider_flock(
|
|
6125
|
+
conn, migration="034 window spend adoption")
|
|
6126
|
+
try:
|
|
6127
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
6128
|
+
try:
|
|
6129
|
+
cache_mod.apply_codex_window_spend_adoption(conn)
|
|
6130
|
+
conn.commit()
|
|
6131
|
+
except Exception:
|
|
6132
|
+
conn.rollback()
|
|
6133
|
+
raise
|
|
6134
|
+
finally:
|
|
6135
|
+
_release_cache_db_writer_flocks(held)
|
|
6136
|
+
|
|
6137
|
+
|
|
6088
6138
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
6089
6139
|
|
|
6090
6140
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -240,6 +240,7 @@ def load_codex_quota_observations(
|
|
|
240
240
|
active_at: dt.datetime | None = None,
|
|
241
241
|
max_rows: int | None = None,
|
|
242
242
|
physical_signatures: dict[str, str] | None = None,
|
|
243
|
+
canonical_resets_between: "tuple[dt.datetime, dt.datetime] | None" = None,
|
|
243
244
|
) -> tuple[QuotaObservation, ...]:
|
|
244
245
|
"""Load only valid root-qualified S1 physical quota rows.
|
|
245
246
|
|
|
@@ -258,6 +259,14 @@ def load_codex_quota_observations(
|
|
|
258
259
|
supplied, exact S2 signatures are accumulated from the same cursor before
|
|
259
260
|
presentation bounds are applied, so coherence validation does not require
|
|
260
261
|
a second unbounded observation load.
|
|
262
|
+
|
|
263
|
+
``canonical_resets_between`` is an INCLUSIVE ``(low, high)`` bound on the
|
|
264
|
+
window's CANONICAL reset, applied in SQL. Unlike ``captured_at_or_after``
|
|
265
|
+
it is a window-IDENTITY bound, so it never fractures a window group: every
|
|
266
|
+
observation of one physical window shares one canonical anchor, and the
|
|
267
|
+
continuity fold below therefore still sees each retained window whole. That
|
|
268
|
+
is what lets the ingest-side spend-adoption pass bound itself to the windows
|
|
269
|
+
one sync touched instead of materializing all history every hook tick.
|
|
261
270
|
"""
|
|
262
271
|
for name, value in (
|
|
263
272
|
("captured_at_or_after", captured_at_or_after), ("active_at", active_at),
|
|
@@ -269,6 +278,19 @@ def load_codex_quota_observations(
|
|
|
269
278
|
captured_at_or_after = value.astimezone(UTC)
|
|
270
279
|
else:
|
|
271
280
|
active_at = value.astimezone(UTC)
|
|
281
|
+
if canonical_resets_between is not None:
|
|
282
|
+
if len(canonical_resets_between) != 2:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
"canonical_resets_between must be a (low, high) pair")
|
|
285
|
+
bounds = []
|
|
286
|
+
for value in canonical_resets_between:
|
|
287
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
"canonical_resets_between must be timezone-aware")
|
|
290
|
+
bounds.append(value.astimezone(UTC))
|
|
291
|
+
if bounds[0] > bounds[1]:
|
|
292
|
+
raise ValueError("canonical_resets_between must be ordered")
|
|
293
|
+
canonical_resets_between = (bounds[0], bounds[1])
|
|
272
294
|
if max_rows is not None:
|
|
273
295
|
if not isinstance(max_rows, int) or isinstance(max_rows, bool) or max_rows <= 0:
|
|
274
296
|
raise ValueError("max_rows must be a positive integer or None")
|
|
@@ -352,6 +374,23 @@ def load_codex_quota_observations(
|
|
|
352
374
|
return ()
|
|
353
375
|
sql += " AND source_root_key IN (" + ",".join("?" for _ in requested) + ")"
|
|
354
376
|
params.extend(sorted(requested))
|
|
377
|
+
if canonical_resets_between is not None:
|
|
378
|
+
# COALESCE, not the bare column: a pre-032 row (or one the backfill
|
|
379
|
+
# never reached) carries NULL there and the reader falls back to the
|
|
380
|
+
# raw reset, so the bound has to fall back with it or the row would
|
|
381
|
+
# silently drop out of its own window.
|
|
382
|
+
reset_expr = (
|
|
383
|
+
"COALESCE(canonical_resets_at_utc, resets_at_utc)"
|
|
384
|
+
if has_anchor else "resets_at_utc"
|
|
385
|
+
)
|
|
386
|
+
sql += (
|
|
387
|
+
f" AND unixepoch({reset_expr}) >= unixepoch(?)"
|
|
388
|
+
f" AND unixepoch({reset_expr}) <= unixepoch(?)"
|
|
389
|
+
)
|
|
390
|
+
params.extend((
|
|
391
|
+
_utc_iso(canonical_resets_between[0]),
|
|
392
|
+
_utc_iso(canonical_resets_between[1]),
|
|
393
|
+
))
|
|
355
394
|
# When exact signatures are requested this first cursor must cover the
|
|
356
395
|
# complete root history. Otherwise apply dashboard presentation bounds
|
|
357
396
|
# in SQL so only the capped evidence crosses the SQLite/Python boundary.
|
|
@@ -494,6 +533,7 @@ def load_codex_quota_observations(
|
|
|
494
533
|
captured_at_or_after=captured_at_or_after,
|
|
495
534
|
active_at=active_at,
|
|
496
535
|
max_rows=max_rows,
|
|
536
|
+
canonical_resets_between=canonical_resets_between,
|
|
497
537
|
)
|
|
498
538
|
if max_rows is not None and len(result) > max_rows:
|
|
499
539
|
result = sorted(
|
|
@@ -1464,13 +1504,30 @@ def _codex_cache_account_predicate(
|
|
|
1464
1504
|
dollars are these" — widening it IS attribution, which D1 forbids, and it
|
|
1465
1505
|
puts one row in two scopes.
|
|
1466
1506
|
|
|
1467
|
-
|
|
1507
|
+
FOUR stamping mechanisms exist and must never be conflated: the
|
|
1468
1508
|
quota-observation fold (``adopt_unidentified_observations``, per physical-
|
|
1469
1509
|
window group, landing post-fold in ``quota_window_blocks`` /
|
|
1470
1510
|
``quota_percent_milestones`` and NEVER written back to
|
|
1471
1511
|
``quota_window_snapshots``); per-file-range attribution
|
|
1472
1512
|
(``codex_file_accounts`` -> ``codex_session_entries.account_key``,
|
|
1473
|
-
``stably_absent`` -> NULL);
|
|
1513
|
+
``stably_absent`` -> NULL); WINDOW-SCOPED SPEND ADOPTION (the 2026-07-30
|
|
1514
|
+
spec — ``_lib_codex_account_adoption`` +
|
|
1515
|
+
``_cctally_cache.apply_codex_window_spend_adoption``, which stamps that same
|
|
1516
|
+
``codex_session_entries.account_key`` column at ingest from the window's
|
|
1517
|
+
single identified account); and the stats ``accounts`` registry.
|
|
1518
|
+
|
|
1519
|
+
So ``codex_session_entries.account_key`` now carries window-derived
|
|
1520
|
+
attribution IN ADDITION to per-file decisions, and that is precisely what
|
|
1521
|
+
keeps the cost read strict rather than forcing it to widen. The rule above
|
|
1522
|
+
genuinely pointed both ways for that read: the scope key comes from the
|
|
1523
|
+
observation fold while the rows came from per-file attribution — DIFFERENT
|
|
1524
|
+
mechanisms, which reads as "widen" — yet widening a cost read is attribution
|
|
1525
|
+
D1 forbids and puts one row in two scopes. The resolution is to make the ROW
|
|
1526
|
+
carry the window's answer durably, so scope key and row now agree by
|
|
1527
|
+
construction and the strict flavour is correct rather than merely safe. A
|
|
1528
|
+
row the adoption pass declined to stamp (zero or ambiguous identified
|
|
1529
|
+
accounts, an overlap whose windows disagree) is genuinely nobody's and
|
|
1530
|
+
stays in the ``unattributed`` scope, which is the honest answer.
|
|
1474
1531
|
"""
|
|
1475
1532
|
if account_key is None:
|
|
1476
1533
|
return "", ()
|
|
@@ -1598,7 +1655,19 @@ def codex_quota_breakdown(
|
|
|
1598
1655
|
renders an honest ``$0.00``; the dollars stay visible in the
|
|
1599
1656
|
``unattributed`` scope, which owns them.
|
|
1600
1657
|
|
|
1601
|
-
|
|
1658
|
+
That honest ``$0.00`` used to fire for spend that was NOT nobody's. The
|
|
1659
|
+
crossing carries the window's account (the observation fold put it there)
|
|
1660
|
+
while the rows behind it carried none, because per-file attribution had no
|
|
1661
|
+
decision covering those bytes. ``codex_session_entries.account_key`` now also
|
|
1662
|
+
carries WINDOW-DERIVED attribution — stamped durably at ingest by
|
|
1663
|
+
``apply_codex_window_spend_adoption`` under the same window key and the same
|
|
1664
|
+
single-identified-account guard the fold uses — so the ladder and the
|
|
1665
|
+
dollars agree by construction. The read stays strict on top of it precisely
|
|
1666
|
+
BECAUSE the inference is now durable: doing it here instead would re-file one
|
|
1667
|
+
row under two scopes on every read, while doing it once at ingest moves the
|
|
1668
|
+
row out of ``unattributed`` and into exactly one owner.
|
|
1669
|
+
|
|
1670
|
+
The full rule, and the four stamping mechanisms it turns on, are in
|
|
1602
1671
|
``_codex_cache_account_predicate``.
|
|
1603
1672
|
"""
|
|
1604
1673
|
reset = _parse_utc(resets_at, "resets_at") if isinstance(resets_at, str) else resets_at
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Codex window-scoped spend adoption (pure kernel).
|
|
2
|
+
|
|
3
|
+
Spec: ``docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md``
|
|
4
|
+
|
|
5
|
+
``adopt_unidentified_observations`` (``bin/_lib_quota.py``) applies the #341 §2
|
|
6
|
+
window-account continuity rule to the OBSERVATION axis: inside one physical
|
|
7
|
+
quota window, unidentified observations are adopted by the window's account iff
|
|
8
|
+
exactly one identified account is ever observed for that window key. This
|
|
9
|
+
kernel applies the same inference, with the same grouping key and the same
|
|
10
|
+
guard, to the SPEND axis — ``codex_session_entries.account_key``.
|
|
11
|
+
|
|
12
|
+
A pure leaf module: stdlib only, no cctally imports. The caller supplies window
|
|
13
|
+
descriptors (already grouped on ``_lib_quota._physical_window_key`` and already
|
|
14
|
+
folded, so ``identified_accounts`` is the window's post-fold identified set) and
|
|
15
|
+
candidate entries; this kernel decides, and only decides. Every SQL read and
|
|
16
|
+
every write stays in the glue layer.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import datetime as dt
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Native length of the account-level Codex weekly quota window, in minutes.
|
|
26
|
+
ACCOUNT_WEEKLY_WINDOW_MINUTES = 10_080
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#: The reserved "account could not be determined" sentinel
|
|
30
|
+
#: (``_lib_accounts.UNATTRIBUTED``), spelled here so this leaf stays import-free.
|
|
31
|
+
UNATTRIBUTED_SENTINEL = "unattributed"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def entry_is_unattributed(account_key: object) -> bool:
|
|
35
|
+
"""Whether a ``codex_session_entries`` row is still up for adoption.
|
|
36
|
+
|
|
37
|
+
``NULL`` is the stamp every never-decided row carries (#416 spec D1:
|
|
38
|
+
``stably_absent`` -> ``NULL``), and the empty string is its degenerate
|
|
39
|
+
spelling. The literal ``unattributed`` sentinel is admitted too: no producer
|
|
40
|
+
writes it to this column today, but ``_codex_cache_account_predicate`` counts
|
|
41
|
+
it in the ``unattributed`` BUCKET, so excluding it here would make such a row
|
|
42
|
+
permanently unadoptable — visible as nobody's money and ineligible for the
|
|
43
|
+
only mechanism that could give it an owner.
|
|
44
|
+
"""
|
|
45
|
+
return (
|
|
46
|
+
account_key is None
|
|
47
|
+
or account_key == ""
|
|
48
|
+
or account_key == UNATTRIBUTED_SENTINEL
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SpendAdoptionWindow:
|
|
54
|
+
"""One physical Codex quota window, as the fold left it.
|
|
55
|
+
|
|
56
|
+
``canonical_resets_at`` is the tolerance-anchored reset (#416 §4.1), never a
|
|
57
|
+
raw jittered provider value — the same anchor
|
|
58
|
+
``_lib_quota._physical_window_key`` groups on. ``identified_accounts`` is
|
|
59
|
+
the set of non-``unattributed`` account keys observed for that key.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
source_root_key: str
|
|
63
|
+
window_minutes: int
|
|
64
|
+
canonical_resets_at: dt.datetime
|
|
65
|
+
identified_accounts: frozenset[str] = frozenset()
|
|
66
|
+
model_scoped: bool = False
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if not isinstance(self.source_root_key, str) or not self.source_root_key:
|
|
70
|
+
raise ValueError("source_root_key must be a non-empty string")
|
|
71
|
+
if (not isinstance(self.window_minutes, int)
|
|
72
|
+
or isinstance(self.window_minutes, bool)
|
|
73
|
+
or self.window_minutes <= 0):
|
|
74
|
+
raise ValueError("window_minutes must be a positive integer")
|
|
75
|
+
reset = self.canonical_resets_at
|
|
76
|
+
if reset.tzinfo is None or reset.utcoffset() is None:
|
|
77
|
+
raise ValueError("canonical_resets_at must be timezone-aware")
|
|
78
|
+
object.__setattr__(
|
|
79
|
+
self, "identified_accounts", frozenset(self.identified_accounts))
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def nominal_start_at(self) -> dt.datetime:
|
|
83
|
+
return self.canonical_resets_at - dt.timedelta(
|
|
84
|
+
minutes=self.window_minutes)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def in_scope(self) -> bool:
|
|
88
|
+
"""Account-level weekly windows only.
|
|
89
|
+
|
|
90
|
+
A 5h window nests inside the weekly one and adds no evidence; a
|
|
91
|
+
model-scoped pool such as GPT-5.3-Codex-Spark is never account weekly
|
|
92
|
+
quota (#373). Both are excluded from candidacy entirely, so neither
|
|
93
|
+
stamps nor blocks.
|
|
94
|
+
"""
|
|
95
|
+
return (
|
|
96
|
+
self.window_minutes == ACCOUNT_WEEKLY_WINDOW_MINUTES
|
|
97
|
+
and not self.model_scoped
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def covers(self, timestamp: dt.datetime) -> bool:
|
|
101
|
+
"""Whether ``timestamp`` falls in the NOMINAL ``[start, reset)`` range.
|
|
102
|
+
|
|
103
|
+
Nominal rather than first-observation: spend before the window's first
|
|
104
|
+
retained observation is still spend inside the cycle.
|
|
105
|
+
"""
|
|
106
|
+
return self.nominal_start_at <= timestamp < self.canonical_resets_at
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class SpendAdoptionCandidate:
|
|
111
|
+
"""One ``codex_session_entries`` row offered to the pass."""
|
|
112
|
+
|
|
113
|
+
entry_id: int
|
|
114
|
+
source_root_key: str
|
|
115
|
+
timestamp: dt.datetime
|
|
116
|
+
account_key: "str | None" = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class SpendAdoptionStamp:
|
|
121
|
+
"""One decided write: give ``entry_id`` this account."""
|
|
122
|
+
|
|
123
|
+
entry_id: int
|
|
124
|
+
account_key: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_spend_adoption_plan(
|
|
128
|
+
windows: Iterable[SpendAdoptionWindow],
|
|
129
|
+
candidates: Iterable[SpendAdoptionCandidate],
|
|
130
|
+
) -> tuple[SpendAdoptionStamp, ...]:
|
|
131
|
+
"""Return the stamping plan, ordered by ``entry_id``.
|
|
132
|
+
|
|
133
|
+
An in-scope window CLAIMS every candidate its nominal range covers on its own
|
|
134
|
+
root. A candidate is stamped iff the UNION of identified accounts across
|
|
135
|
+
every claiming window is exactly one.
|
|
136
|
+
|
|
137
|
+
A claiming window that identifies no account contributes nothing to that
|
|
138
|
+
union and therefore does NOT block: absence of evidence is not evidence of
|
|
139
|
+
ambiguity. That is the same shape ``adopt_unidentified_observations`` uses on
|
|
140
|
+
the observation axis, which likewise resolves a window from its *identified*
|
|
141
|
+
observations only and treats an unidentified population as no evidence. The
|
|
142
|
+
first implementation blocked on any claiming window that resolved to nothing
|
|
143
|
+
and measured ZERO stamped rows on a real store: weekly resets move by days,
|
|
144
|
+
so one cycle overlaps many neighbours and pre-attribution history is
|
|
145
|
+
unidentified by construction — one such neighbour was always enough to veto.
|
|
146
|
+
|
|
147
|
+
Two claiming windows naming DIFFERENT accounts still leave the entry alone
|
|
148
|
+
(union of two), and so does a single window that itself saw two accounts
|
|
149
|
+
(#341 never-combine). An already-identified row is never re-stamped, so
|
|
150
|
+
re-running over this kernel's own output returns an empty plan.
|
|
151
|
+
"""
|
|
152
|
+
by_root: dict[str, list[SpendAdoptionWindow]] = {}
|
|
153
|
+
for window in windows:
|
|
154
|
+
if window.in_scope:
|
|
155
|
+
by_root.setdefault(window.source_root_key, []).append(window)
|
|
156
|
+
|
|
157
|
+
stamps: list[SpendAdoptionStamp] = []
|
|
158
|
+
for candidate in candidates:
|
|
159
|
+
if not entry_is_unattributed(candidate.account_key):
|
|
160
|
+
continue
|
|
161
|
+
identified: set[str] = set()
|
|
162
|
+
for window in by_root.get(candidate.source_root_key, ()):
|
|
163
|
+
if window.covers(candidate.timestamp):
|
|
164
|
+
identified |= window.identified_accounts
|
|
165
|
+
if len(identified) > 1:
|
|
166
|
+
break
|
|
167
|
+
if len(identified) != 1:
|
|
168
|
+
continue
|
|
169
|
+
account = next(iter(identified))
|
|
170
|
+
stamps.append(SpendAdoptionStamp(
|
|
171
|
+
entry_id=candidate.entry_id, account_key=account))
|
|
172
|
+
stamps.sort(key=lambda stamp: (stamp.entry_id, stamp.account_key))
|
|
173
|
+
return tuple(stamps)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.87.1",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"bin/_lib_cache_report.py",
|
|
70
70
|
"bin/_lib_cache_writer_lock.py",
|
|
71
71
|
"bin/_lib_changelog.py",
|
|
72
|
+
"bin/_lib_codex_account_adoption.py",
|
|
72
73
|
"bin/_lib_codex_conversation.py",
|
|
73
74
|
"bin/_lib_codex_conversation_export.py",
|
|
74
75
|
"bin/_lib_codex_conversation_query.py",
|