cctally 1.17.0 → 1.19.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.
- package/CHANGELOG.md +10 -0
- package/bin/_cctally_cache.py +108 -24
- package/bin/_lib_aggregators.py +103 -19
- package/bin/_lib_blocks.py +55 -1
- package/bin/_lib_doctor.py +1 -1
- package/bin/_lib_render.py +296 -95
- package/bin/cctally +399 -47
- package/package.json +1 -1
package/bin/cctally
CHANGED
|
@@ -349,6 +349,8 @@ _group_entries_into_blocks = _lib_blocks._group_entries_into_blocks
|
|
|
349
349
|
_aggregate_block = _lib_blocks._aggregate_block
|
|
350
350
|
_build_activity_block = _lib_blocks._build_activity_block
|
|
351
351
|
_blocks_to_json = _lib_blocks._blocks_to_json
|
|
352
|
+
_max_completed_block_tokens = _lib_blocks._max_completed_block_tokens
|
|
353
|
+
_parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
|
|
352
354
|
|
|
353
355
|
_lib_changelog = _load_sibling("_lib_changelog")
|
|
354
356
|
# Backward-compat re-export: callers and tests reach the helper through
|
|
@@ -368,6 +370,7 @@ CodexSessionUsage = _lib_aggregators.CodexSessionUsage
|
|
|
368
370
|
ClaudeSessionUsage = _lib_aggregators.ClaudeSessionUsage
|
|
369
371
|
_aggregate_buckets = _lib_aggregators._aggregate_buckets
|
|
370
372
|
_aggregate_daily = _lib_aggregators._aggregate_daily
|
|
373
|
+
_aggregate_daily_by_project = _lib_aggregators._aggregate_daily_by_project
|
|
371
374
|
_aggregate_monthly = _lib_aggregators._aggregate_monthly
|
|
372
375
|
_aggregate_weekly = _lib_aggregators._aggregate_weekly
|
|
373
376
|
_aggregate_codex_buckets = _lib_aggregators._aggregate_codex_buckets
|
|
@@ -408,7 +411,11 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
|
408
411
|
_lib_render = _load_sibling("_lib_render")
|
|
409
412
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
410
413
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
414
|
+
_render_active_block_box = _lib_render._render_active_block_box
|
|
415
|
+
_daily_row_dict = _lib_render._daily_row_dict
|
|
416
|
+
_bucket_totals_dict = _lib_render._bucket_totals_dict
|
|
411
417
|
_bucket_to_json = _lib_render._bucket_to_json
|
|
418
|
+
_bucket_by_project_to_json = _lib_render._bucket_by_project_to_json
|
|
412
419
|
_weekly_to_json = _lib_render._weekly_to_json
|
|
413
420
|
_daily_compact_split = _lib_render._daily_compact_split
|
|
414
421
|
_monthly_compact_split = _lib_render._monthly_compact_split
|
|
@@ -614,7 +621,6 @@ cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
|
614
621
|
_cctally_cache = _load_sibling("_cctally_cache")
|
|
615
622
|
ProjectKey = _cctally_cache.ProjectKey
|
|
616
623
|
_resolve_project_key = _cctally_cache._resolve_project_key
|
|
617
|
-
_get_codex_sessions_dir = _cctally_cache._get_codex_sessions_dir
|
|
618
624
|
_discover_codex_session_files = _cctally_cache._discover_codex_session_files
|
|
619
625
|
IngestStats = _cctally_cache.IngestStats
|
|
620
626
|
_progress_stderr = _cctally_cache._progress_stderr
|
|
@@ -871,6 +877,84 @@ LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
|
|
|
871
877
|
|
|
872
878
|
CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
|
|
873
879
|
|
|
880
|
+
|
|
881
|
+
def _codex_home_roots() -> list[pathlib.Path]:
|
|
882
|
+
"""ALL raw $CODEX_HOME entries (comma-split), else [~/.codex].
|
|
883
|
+
|
|
884
|
+
These are the entries upstream calls "CODEX_HOME roots" — the full
|
|
885
|
+
comma-split list, BEFORE the sessions/-subdir rule decides home-vs-direct.
|
|
886
|
+
The config reader (_detect_codex_fast_service_tier) reads <entry>/config.toml
|
|
887
|
+
for EVERY entry returned here, including ones that turn out to be direct
|
|
888
|
+
JSONL dirs; only _codex_session_roots() applies the sessions/-subdir
|
|
889
|
+
narrowing. Blank/whitespace entries are dropped; each is expanduser'd
|
|
890
|
+
(literal '~'; the shell already expands $VAR before we see the value)
|
|
891
|
+
then made ABSOLUTE via .absolute() — a relative $CODEX_HOME (e.g.
|
|
892
|
+
`./codexA`) would otherwise glob and store relative source_paths, which
|
|
893
|
+
the cache-prune step cannot distinguish from synthetic relative-path
|
|
894
|
+
fixture rows (issue #108; see the prune guard in _cctally_cache.py).
|
|
895
|
+
Canonicalizing here makes every REAL ingested source_path absolute, so
|
|
896
|
+
the prune's `isabs` fixture carve-out is correct by construction.
|
|
897
|
+
Order is preserved (first-match-wins downstream).
|
|
898
|
+
"""
|
|
899
|
+
raw = os.environ.get("CODEX_HOME", "").strip()
|
|
900
|
+
if not raw:
|
|
901
|
+
return [pathlib.Path.home() / ".codex"]
|
|
902
|
+
roots: list[pathlib.Path] = []
|
|
903
|
+
saw_part = False
|
|
904
|
+
for part in raw.split(","):
|
|
905
|
+
part = part.strip()
|
|
906
|
+
if not part:
|
|
907
|
+
continue
|
|
908
|
+
saw_part = True
|
|
909
|
+
try:
|
|
910
|
+
# expanduser() raises RuntimeError on a malformed `~user` entry
|
|
911
|
+
# (e.g. `~nonexistentuser/x` — the user doesn't exist). Drop the
|
|
912
|
+
# bad entry rather than aborting the whole command, so valid
|
|
913
|
+
# roots beside it survive. .absolute() canonicalizes a relative
|
|
914
|
+
# entry (e.g. `./codexA`) against cwd so real ingested source_paths
|
|
915
|
+
# are always absolute (issue #108 — keeps the cache-prune correct).
|
|
916
|
+
roots.append(pathlib.Path(part).expanduser().absolute())
|
|
917
|
+
except (RuntimeError, OSError, ValueError):
|
|
918
|
+
continue
|
|
919
|
+
# Fall back to the default home ONLY when the variable is unset or carries
|
|
920
|
+
# no non-blank entry at all. An explicit-but-all-invalid value (e.g. a lone
|
|
921
|
+
# `~baduser` that expanduser() drops) yields [] — respect the override and
|
|
922
|
+
# read nothing rather than silently reading the default account (issue
|
|
923
|
+
# #108). A plain dead path like `/typo` already reaches [] via the is_dir()
|
|
924
|
+
# filter in _codex_session_roots(); this aligns the `~user` flavor with it.
|
|
925
|
+
if not saw_part:
|
|
926
|
+
return [pathlib.Path.home() / ".codex"]
|
|
927
|
+
return roots
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _codex_session_roots() -> list[pathlib.Path]:
|
|
931
|
+
"""Directories to walk for *.jsonl, applying the sessions/-subdir rule.
|
|
932
|
+
|
|
933
|
+
For each home root r (in $CODEX_HOME order):
|
|
934
|
+
(r / "sessions").is_dir() -> r / "sessions" (Codex home)
|
|
935
|
+
elif r.is_dir() -> r (direct JSONL dir)
|
|
936
|
+
else -> skipped
|
|
937
|
+
Exact-duplicate roots are de-duped (first occurrence kept). File-level
|
|
938
|
+
de-dup for overlapping/prefix roots happens in the discovery walkers
|
|
939
|
+
(set of absolute paths); this function only collapses identical roots.
|
|
940
|
+
"""
|
|
941
|
+
out: list[pathlib.Path] = []
|
|
942
|
+
seen: set[pathlib.Path] = set()
|
|
943
|
+
for r in _codex_home_roots():
|
|
944
|
+
sess = r / "sessions"
|
|
945
|
+
if sess.is_dir():
|
|
946
|
+
cand = sess
|
|
947
|
+
elif r.is_dir():
|
|
948
|
+
cand = r
|
|
949
|
+
else:
|
|
950
|
+
continue
|
|
951
|
+
if cand in seen:
|
|
952
|
+
continue
|
|
953
|
+
seen.add(cand)
|
|
954
|
+
out.append(cand)
|
|
955
|
+
return out
|
|
956
|
+
|
|
957
|
+
|
|
874
958
|
# Note: `_cctally_db` reads its four path constants
|
|
875
959
|
# (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
|
|
876
960
|
# `_cctally_core.X` at call time — the canonical sibling pattern after
|
|
@@ -1538,16 +1622,27 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
|
|
|
1538
1622
|
The joined-entry shape already carries ``source_path``, ``cost_usd``,
|
|
1539
1623
|
and the per-token integers; this adapter is pure shape conversion
|
|
1540
1624
|
with no cache re-read.
|
|
1625
|
+
|
|
1626
|
+
Non-token ``usage`` extras (``je.usage_extra``) — notably ``speed`` —
|
|
1627
|
+
are merged AFTER the four token keys, mirroring ``iter_entries``'
|
|
1628
|
+
``usage.update(json.loads(...))``. Without this, the project-axis
|
|
1629
|
+
``daily`` path (and the diff/report joined-entry consumers) would
|
|
1630
|
+
drop the fast-tier flag and render ``<model>`` where the normal path
|
|
1631
|
+
renders ``<model>-fast``. The write-side strips the four token keys
|
|
1632
|
+
from ``usage_extra_json`` so the merge never shadows the integers.
|
|
1541
1633
|
"""
|
|
1634
|
+
usage = {
|
|
1635
|
+
"input_tokens": je.input_tokens,
|
|
1636
|
+
"output_tokens": je.output_tokens,
|
|
1637
|
+
"cache_creation_input_tokens": je.cache_creation_tokens,
|
|
1638
|
+
"cache_read_input_tokens": je.cache_read_tokens,
|
|
1639
|
+
}
|
|
1640
|
+
if je.usage_extra:
|
|
1641
|
+
usage.update(je.usage_extra)
|
|
1542
1642
|
return UsageEntry(
|
|
1543
1643
|
timestamp=je.timestamp,
|
|
1544
1644
|
model=je.model,
|
|
1545
|
-
usage=
|
|
1546
|
-
"input_tokens": je.input_tokens,
|
|
1547
|
-
"output_tokens": je.output_tokens,
|
|
1548
|
-
"cache_creation_input_tokens": je.cache_creation_tokens,
|
|
1549
|
-
"cache_read_input_tokens": je.cache_read_tokens,
|
|
1550
|
-
},
|
|
1645
|
+
usage=usage,
|
|
1551
1646
|
cost_usd=je.cost_usd,
|
|
1552
1647
|
source_path=je.source_path,
|
|
1553
1648
|
)
|
|
@@ -1586,6 +1681,42 @@ def _project_filter_matches(key, project_patterns):
|
|
|
1586
1681
|
return any((p in dname) or (p in pname) for p in project_patterns)
|
|
1587
1682
|
|
|
1588
1683
|
|
|
1684
|
+
def _parse_project_aliases(raw):
|
|
1685
|
+
"""Parse a ``--project-aliases`` value into ``{key: label}``.
|
|
1686
|
+
|
|
1687
|
+
Form: comma-separated ``key=Label`` pairs. Whitespace around keys, labels,
|
|
1688
|
+
and pairs is stripped; segments without ``=`` or with an empty key/label are
|
|
1689
|
+
dropped (ported from ccusage's tolerant parser). ``None``/"" → ``{}``.
|
|
1690
|
+
"""
|
|
1691
|
+
result: dict[str, str] = {}
|
|
1692
|
+
if not raw:
|
|
1693
|
+
return result
|
|
1694
|
+
for pair in raw.split(","):
|
|
1695
|
+
pair = pair.strip()
|
|
1696
|
+
if not pair or "=" not in pair:
|
|
1697
|
+
continue
|
|
1698
|
+
k, _, v = pair.partition("=")
|
|
1699
|
+
k = k.strip()
|
|
1700
|
+
v = v.strip()
|
|
1701
|
+
if k and v:
|
|
1702
|
+
result[k] = v
|
|
1703
|
+
return result
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def _alias_for(key, aliases):
|
|
1707
|
+
"""Return the alias label for a ProjectKey, or None.
|
|
1708
|
+
|
|
1709
|
+
Looks up ``aliases`` by ``display_key``, then ``git_root``, then
|
|
1710
|
+
``bucket_path`` (first hit). Display-only — never alters JSON keys.
|
|
1711
|
+
"""
|
|
1712
|
+
if not aliases:
|
|
1713
|
+
return None
|
|
1714
|
+
for cand in (key.display_key, key.git_root, key.bucket_path):
|
|
1715
|
+
if cand and cand in aliases:
|
|
1716
|
+
return aliases[cand]
|
|
1717
|
+
return None
|
|
1718
|
+
|
|
1719
|
+
|
|
1589
1720
|
def _emit_diff_debug_samples(args, window_a, window_b) -> None:
|
|
1590
1721
|
"""Two-window diff report (spec §7.2.2 Pattern D).
|
|
1591
1722
|
|
|
@@ -4172,6 +4303,15 @@ def _load_recorded_five_hour_windows(
|
|
|
4172
4303
|
|
|
4173
4304
|
def cmd_blocks(args: argparse.Namespace) -> int:
|
|
4174
4305
|
"""Show usage report grouped by 5-hour session blocks."""
|
|
4306
|
+
# -n/--session-length guard (#86 Session F). The flag is a documented
|
|
4307
|
+
# no-op (cctally blocks anchor to Anthropic's real 5h resets and are not
|
|
4308
|
+
# re-sizable), but a non-positive value still errors for drop-in fidelity
|
|
4309
|
+
# with ccusage's "Session length must be a positive number". Runs first,
|
|
4310
|
+
# before any data load — matches ccusage's command-flow ordering.
|
|
4311
|
+
if getattr(args, "session_length", 5.0) <= 0:
|
|
4312
|
+
eprint("blocks: session length must be a positive number")
|
|
4313
|
+
return 1
|
|
4314
|
+
|
|
4175
4315
|
config = _load_claude_config_for_args(args)
|
|
4176
4316
|
_bridge_z_into_tz(args, config)
|
|
4177
4317
|
tz = resolve_display_tz(args, config)
|
|
@@ -4271,16 +4411,60 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
4271
4411
|
# 20:50→01:50 with $45 cost vs the real $128).
|
|
4272
4412
|
_maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
|
|
4273
4413
|
|
|
4414
|
+
# ── Session F (#86): resolve token limit, then filter ────────────────
|
|
4415
|
+
# Auto-max baseline over ALL blocks (before --recent/--active filtering),
|
|
4416
|
+
# matching ccusage's maxTokensFromAll.
|
|
4417
|
+
max_completed = _max_completed_block_tokens(blocks)
|
|
4418
|
+
token_limit = _parse_blocks_token_limit(
|
|
4419
|
+
getattr(args, "token_limit", None), max_completed
|
|
4420
|
+
)
|
|
4421
|
+
# ``token_limit_explicit`` is the resolved limit ONLY when -t was passed
|
|
4422
|
+
# (any value incl. "max"); the implicit default leaves it None so the
|
|
4423
|
+
# box's Token Limit Status sub-block + the JSON tokenLimitStatus key are
|
|
4424
|
+
# omitted (ccusage `if (tokenLimit != null)` gate).
|
|
4425
|
+
token_limit_explicit = (
|
|
4426
|
+
token_limit if getattr(args, "token_limit", None) is not None else None
|
|
4427
|
+
)
|
|
4428
|
+
auto_max = getattr(args, "token_limit", None) in (None, "", "max")
|
|
4429
|
+
if auto_max and token_limit and not args.json:
|
|
4430
|
+
# ccusage parity: logger.info → stdout (Codex F1). Suppressed under
|
|
4431
|
+
# --json (ccusage sets logger.level=0), so --json goldens stay stable.
|
|
4432
|
+
print(f"Using max tokens from previous sessions: {_fmt_num(token_limit)}")
|
|
4433
|
+
|
|
4434
|
+
if getattr(args, "recent", False):
|
|
4435
|
+
cutoff = now_utc - dt.timedelta(days=3)
|
|
4436
|
+
blocks = [b for b in blocks if b.start_time >= cutoff or b.is_active]
|
|
4437
|
+
|
|
4438
|
+
if getattr(args, "active", False):
|
|
4439
|
+
blocks = [b for b in blocks if b.is_active and not b.is_gap]
|
|
4440
|
+
if not blocks:
|
|
4441
|
+
if args.json:
|
|
4442
|
+
print('{\n "blocks": [],\n "message": "No active block"\n}')
|
|
4443
|
+
else:
|
|
4444
|
+
print("No active session block found.")
|
|
4445
|
+
return 0
|
|
4446
|
+
|
|
4274
4447
|
if args.json:
|
|
4275
|
-
print(_blocks_to_json(blocks))
|
|
4448
|
+
print(_blocks_to_json(blocks, token_limit_status_limit=token_limit_explicit))
|
|
4449
|
+
return 0
|
|
4450
|
+
|
|
4451
|
+
if getattr(args, "active", False) and len(blocks) == 1:
|
|
4452
|
+
print(_render_active_block_box(
|
|
4453
|
+
blocks[0], now=now_utc, tz=tz,
|
|
4454
|
+
token_limit_explicit=token_limit_explicit,
|
|
4455
|
+
color=_supports_color_stdout(), unicode_ok=_supports_unicode_stdout(),
|
|
4456
|
+
))
|
|
4276
4457
|
return 0
|
|
4277
4458
|
|
|
4278
4459
|
# Table output. Session A (spec §7.6.1; Review-A P2-B): thread
|
|
4279
4460
|
# --compact through so the renderer's scale-down branch fires
|
|
4280
|
-
# regardless of terminal width when the flag is set.
|
|
4461
|
+
# regardless of terminal width when the flag is set. Session F: thread
|
|
4462
|
+
# the resolved token_limit so an explicit -t keys the %/REMAINING/
|
|
4463
|
+
# PROJECTED surface (the default path passes the same auto-max the
|
|
4464
|
+
# renderer computed internally, so it stays byte-identical).
|
|
4281
4465
|
print(_render_blocks_table(
|
|
4282
4466
|
blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
|
|
4283
|
-
compact=getattr(args, "compact", False),
|
|
4467
|
+
compact=getattr(args, "compact", False), token_limit=token_limit,
|
|
4284
4468
|
))
|
|
4285
4469
|
return 0
|
|
4286
4470
|
|
|
@@ -4512,6 +4696,29 @@ def _parse_cli_date_range(
|
|
|
4512
4696
|
return range_start, range_end
|
|
4513
4697
|
|
|
4514
4698
|
|
|
4699
|
+
def _emit_daily_view_table_or_json(view, args):
|
|
4700
|
+
"""Order + emit a DailyView as the flat daily table or {daily} JSON.
|
|
4701
|
+
|
|
4702
|
+
Shared by cmd_daily's default path and its -p-only (filter, no grouping)
|
|
4703
|
+
path so the two cannot drift. Body is exactly the default path's order +
|
|
4704
|
+
emit tail; callers keep their own --format share gate upstream of this.
|
|
4705
|
+
"""
|
|
4706
|
+
days = list(reversed(view.aggregated))
|
|
4707
|
+
if args.order == "desc":
|
|
4708
|
+
days = list(reversed(days))
|
|
4709
|
+
if args.json:
|
|
4710
|
+
print(_bucket_to_json(days, list_key="daily", date_key="date"))
|
|
4711
|
+
return
|
|
4712
|
+
print(_render_bucket_table(
|
|
4713
|
+
days,
|
|
4714
|
+
first_col_name="Date",
|
|
4715
|
+
title_suffix="Daily",
|
|
4716
|
+
compact_split_fn=_daily_compact_split,
|
|
4717
|
+
breakdown=args.breakdown,
|
|
4718
|
+
compact=getattr(args, "compact", False),
|
|
4719
|
+
))
|
|
4720
|
+
|
|
4721
|
+
|
|
4515
4722
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
4516
4723
|
"""Show usage report grouped by display-timezone date."""
|
|
4517
4724
|
_share_validate_args(args)
|
|
@@ -4533,6 +4740,109 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
4533
4740
|
return range
|
|
4534
4741
|
range_start, range_end = range
|
|
4535
4742
|
|
|
4743
|
+
# ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
|
|
4744
|
+
# Gated by -i/--instances or -p/--project; the default path below is
|
|
4745
|
+
# untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
|
|
4746
|
+
# resolution + substring-OR-path filter.
|
|
4747
|
+
aliases = _parse_project_aliases(getattr(args, "project_aliases", None))
|
|
4748
|
+
project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
|
|
4749
|
+
|
|
4750
|
+
if getattr(args, "instances", False) or project_patterns:
|
|
4751
|
+
joined = list(get_claude_session_entries(range_start, range_end))
|
|
4752
|
+
resolver_cache: dict = {}
|
|
4753
|
+
keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
|
|
4754
|
+
filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
|
|
4755
|
+
for je in joined:
|
|
4756
|
+
if je.model == "<synthetic>":
|
|
4757
|
+
continue
|
|
4758
|
+
key = _resolve_project_key(je.project_path, "git-root", resolver_cache)
|
|
4759
|
+
if project_patterns and not _project_filter_matches(key, project_patterns):
|
|
4760
|
+
continue
|
|
4761
|
+
ue = _usage_entry_from_joined(je)
|
|
4762
|
+
keyed.append((key, ue))
|
|
4763
|
+
filtered_uentries.append(ue)
|
|
4764
|
+
|
|
4765
|
+
# Debug scope = the filtered entries (mirrors cmd_project).
|
|
4766
|
+
_emit_debug_samples_if_set(args, filtered_uentries, command_label="daily")
|
|
4767
|
+
|
|
4768
|
+
# --format share gate: -i is a no-op (no project-section share render),
|
|
4769
|
+
# but -p IS honored by building the snapshot from the filtered view.
|
|
4770
|
+
if getattr(args, "format", None):
|
|
4771
|
+
view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
|
|
4772
|
+
display_tz=tz, mode=args.mode)
|
|
4773
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
4774
|
+
snap = _build_daily_snapshot(
|
|
4775
|
+
view, period_start=range_start, period_end=range_end,
|
|
4776
|
+
display_tz=display_tz_str, version=_share_resolve_version(),
|
|
4777
|
+
theme=args.theme, reveal_projects=args.reveal_projects,
|
|
4778
|
+
)
|
|
4779
|
+
if args.order == "desc":
|
|
4780
|
+
snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
|
|
4781
|
+
_share_render_and_emit(snap, args)
|
|
4782
|
+
return 0
|
|
4783
|
+
|
|
4784
|
+
if getattr(args, "instances", False):
|
|
4785
|
+
groups = _aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
|
|
4786
|
+
aug = _project_disambiguate_labels(
|
|
4787
|
+
[{"key": k, "cost_usd": sum(b.cost_usd for b in bl)}
|
|
4788
|
+
for k, bl in groups]
|
|
4789
|
+
)
|
|
4790
|
+
json_groups: list = []
|
|
4791
|
+
table_groups: list = []
|
|
4792
|
+
# `_project_disambiguate_labels` only suffixes the immediate
|
|
4793
|
+
# parent-dir basename, so two distinct git-roots like
|
|
4794
|
+
# `/a/x/app` + `/b/x/app` both resolve to `app (x)`. Guarantee
|
|
4795
|
+
# per-group JSON-key uniqueness with a counter suffix on any
|
|
4796
|
+
# residual collision — otherwise `_bucket_by_project_to_json`'s
|
|
4797
|
+
# `projects[label] = ...` silently overwrites the earlier group
|
|
4798
|
+
# (data loss in --json). The table_label derives from the now-
|
|
4799
|
+
# unique json_label, so section headers stay distinct too.
|
|
4800
|
+
# `json_label`s are unique by construction (the `(#N)` counter
|
|
4801
|
+
# above). Table labels, however, can re-collide: `_alias_for`
|
|
4802
|
+
# matches on `display_key` first, so a basename alias like
|
|
4803
|
+
# `--project-aliases app=Alias` maps BOTH same-basename git-roots
|
|
4804
|
+
# to "Alias" — re-merging the exact sections this feature
|
|
4805
|
+
# disambiguates. Apply the SAME `(#N)` counter to table labels so
|
|
4806
|
+
# the two distinct-total sections stay tellable apart (JSON keys
|
|
4807
|
+
# are untouched — they use the non-aliased `json_label`).
|
|
4808
|
+
seen_json_labels: dict[str, int] = {}
|
|
4809
|
+
seen_table_labels: dict[str, int] = {}
|
|
4810
|
+
for i, (k, bl) in enumerate(groups):
|
|
4811
|
+
ordered = list(reversed(bl)) if args.order == "desc" else bl
|
|
4812
|
+
base_json_label = aug.get(i, k.display_key)
|
|
4813
|
+
n = seen_json_labels.get(base_json_label, 0) + 1
|
|
4814
|
+
seen_json_labels[base_json_label] = n
|
|
4815
|
+
json_label = (
|
|
4816
|
+
base_json_label if n == 1 else f"{base_json_label} (#{n})"
|
|
4817
|
+
)
|
|
4818
|
+
base_table_label = _alias_for(k, aliases) or json_label
|
|
4819
|
+
nt = seen_table_labels.get(base_table_label, 0) + 1
|
|
4820
|
+
seen_table_labels[base_table_label] = nt
|
|
4821
|
+
table_label = (
|
|
4822
|
+
base_table_label if nt == 1
|
|
4823
|
+
else f"{base_table_label} (#{nt})"
|
|
4824
|
+
)
|
|
4825
|
+
json_groups.append((json_label, ordered))
|
|
4826
|
+
table_groups.append((table_label, ordered))
|
|
4827
|
+
if args.json:
|
|
4828
|
+
print(_bucket_by_project_to_json(json_groups, date_key="date"))
|
|
4829
|
+
return 0
|
|
4830
|
+
print(_render_bucket_table(
|
|
4831
|
+
[], first_col_name="Date", title_suffix="Daily",
|
|
4832
|
+
compact_split_fn=_daily_compact_split,
|
|
4833
|
+
breakdown=args.breakdown,
|
|
4834
|
+
compact=getattr(args, "compact", False),
|
|
4835
|
+
project_groups=table_groups,
|
|
4836
|
+
))
|
|
4837
|
+
return 0
|
|
4838
|
+
|
|
4839
|
+
# -p only (no -i): filter-only → normal date-aggregated daily output.
|
|
4840
|
+
view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
|
|
4841
|
+
display_tz=tz, mode=args.mode)
|
|
4842
|
+
_emit_daily_view_table_or_json(view, args)
|
|
4843
|
+
return 0
|
|
4844
|
+
|
|
4845
|
+
# ── Default path (UNCHANGED) ───────────────────────────────────────────
|
|
4536
4846
|
# Collect entries.
|
|
4537
4847
|
all_entries = get_entries(range_start, range_end)
|
|
4538
4848
|
|
|
@@ -4549,10 +4859,6 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
4549
4859
|
# `_aggregate_daily` call is the same one we used inline.
|
|
4550
4860
|
view = build_daily_view(all_entries, now_utc=_command_as_of(),
|
|
4551
4861
|
display_tz=tz, mode=args.mode)
|
|
4552
|
-
# `_aggregate_daily` returned ascending order; build_daily_view stores
|
|
4553
|
-
# `aggregated` newest-first. CLI's default order is ascending, so
|
|
4554
|
-
# re-reverse to match the prior on-the-wire shape.
|
|
4555
|
-
days = list(reversed(view.aggregated))
|
|
4556
4862
|
|
|
4557
4863
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
4558
4864
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -4583,23 +4889,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
4583
4889
|
_share_render_and_emit(snap, args)
|
|
4584
4890
|
return 0
|
|
4585
4891
|
|
|
4586
|
-
#
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
if args.json:
|
|
4591
|
-
print(_bucket_to_json(days, list_key="daily", date_key="date"))
|
|
4592
|
-
return 0
|
|
4593
|
-
|
|
4594
|
-
# Table output.
|
|
4595
|
-
print(_render_bucket_table(
|
|
4596
|
-
days,
|
|
4597
|
-
first_col_name="Date",
|
|
4598
|
-
title_suffix="Daily",
|
|
4599
|
-
compact_split_fn=_daily_compact_split,
|
|
4600
|
-
breakdown=args.breakdown,
|
|
4601
|
-
compact=getattr(args, "compact", False),
|
|
4602
|
-
))
|
|
4892
|
+
# Order + emit the flat daily table / {daily} JSON. Extracted into
|
|
4893
|
+
# `_emit_daily_view_table_or_json` so this default path and the
|
|
4894
|
+
# -p-only (filter, no grouping) path above stay byte-identical.
|
|
4895
|
+
_emit_daily_view_table_or_json(view, args)
|
|
4603
4896
|
return 0
|
|
4604
4897
|
|
|
4605
4898
|
|
|
@@ -4811,25 +5104,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
4811
5104
|
|
|
4812
5105
|
|
|
4813
5106
|
def _detect_codex_fast_service_tier() -> bool:
|
|
4814
|
-
"""True iff
|
|
4815
|
-
|
|
4816
|
-
Reads
|
|
4817
|
-
|
|
4818
|
-
|
|
5107
|
+
"""True iff any $CODEX_HOME root's config.toml requests fast/priority tier.
|
|
5108
|
+
|
|
5109
|
+
Reads <root>/config.toml for EVERY entry in _codex_home_roots() (comma-
|
|
5110
|
+
separated $CODEX_HOME, else ~/.codex) — including direct-JSONL entries,
|
|
5111
|
+
which usually have no config.toml (read → absent → skipped) but DO count
|
|
5112
|
+
if one is present. Returns on the first root that requests it (any-root
|
|
5113
|
+
semantics, matching upstream ccusage). Tolerates absent/unreadable config
|
|
5114
|
+
(→ that root contributes nothing).
|
|
4819
5115
|
"""
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
5116
|
+
for root in _codex_home_roots():
|
|
5117
|
+
cfg = root / "config.toml"
|
|
5118
|
+
try:
|
|
5119
|
+
content = cfg.read_text(encoding="utf-8", errors="replace")
|
|
5120
|
+
except OSError:
|
|
5121
|
+
continue
|
|
5122
|
+
if _codex_config_requests_fast_service_tier(content):
|
|
5123
|
+
return True
|
|
5124
|
+
return False
|
|
4826
5125
|
|
|
4827
5126
|
|
|
4828
5127
|
def _resolve_codex_speed(requested: str) -> str:
|
|
4829
5128
|
"""Resolve a ``--speed`` value to an effective tier.
|
|
4830
5129
|
|
|
4831
|
-
``auto`` → ``fast`` iff
|
|
4832
|
-
``standard``. ``fast``/``standard`` pass through
|
|
5130
|
+
``auto`` → ``fast`` iff any ``$CODEX_HOME`` root's ``config.toml``
|
|
5131
|
+
requests it, else ``standard``. ``fast``/``standard`` pass through
|
|
5132
|
+
unchanged.
|
|
4833
5133
|
"""
|
|
4834
5134
|
if requested == "auto":
|
|
4835
5135
|
return "fast" if _detect_codex_fast_service_tier() else "standard"
|
|
@@ -9670,11 +9970,16 @@ def doctor_gather_state(
|
|
|
9670
9970
|
except Exception:
|
|
9671
9971
|
pass
|
|
9672
9972
|
|
|
9973
|
+
# Issue #109: probe every $CODEX_HOME session root (not the single
|
|
9974
|
+
# hardcoded ~/.codex/sessions), matching the multi-root ingestion path
|
|
9975
|
+
# from #108. _codex_session_roots() already applies the sessions/-subdir
|
|
9976
|
+
# rule and filters to existing dirs, so a bare glob per root suffices.
|
|
9673
9977
|
codex_jsonl_present = False
|
|
9674
9978
|
try:
|
|
9675
|
-
codex_dir
|
|
9676
|
-
|
|
9677
|
-
|
|
9979
|
+
for codex_dir in _codex_session_roots():
|
|
9980
|
+
if next(codex_dir.glob("**/*.jsonl"), None) is not None:
|
|
9981
|
+
codex_jsonl_present = True
|
|
9982
|
+
break
|
|
9678
9983
|
except Exception:
|
|
9679
9984
|
pass
|
|
9680
9985
|
|
|
@@ -10173,6 +10478,8 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
|
|
|
10173
10478
|
cctally daily --since 20260414 --breakdown
|
|
10174
10479
|
cctally daily --since 20260414 --json
|
|
10175
10480
|
cctally daily --order desc
|
|
10481
|
+
cctally daily --instances
|
|
10482
|
+
cctally daily -i --project-aliases repos=Repos
|
|
10176
10483
|
"""),
|
|
10177
10484
|
)
|
|
10178
10485
|
p.add_argument(
|
|
@@ -10210,6 +10517,28 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
|
|
|
10210
10517
|
help="Display timezone: local, utc, or IANA name. "
|
|
10211
10518
|
"Overrides config display.tz for this call.",
|
|
10212
10519
|
)
|
|
10520
|
+
p.add_argument(
|
|
10521
|
+
"-i", "--instances",
|
|
10522
|
+
action="store_true",
|
|
10523
|
+
default=False,
|
|
10524
|
+
help="Group the report by project (git-root).",
|
|
10525
|
+
)
|
|
10526
|
+
p.add_argument(
|
|
10527
|
+
"-p", "--project",
|
|
10528
|
+
action="append",
|
|
10529
|
+
default=None,
|
|
10530
|
+
metavar="PATTERN",
|
|
10531
|
+
help="Filter to projects matching PATTERN (substring of the project "
|
|
10532
|
+
"label or path; repeatable, OR semantics).",
|
|
10533
|
+
)
|
|
10534
|
+
p.add_argument(
|
|
10535
|
+
"--project-aliases",
|
|
10536
|
+
dest="project_aliases",
|
|
10537
|
+
default=None,
|
|
10538
|
+
metavar="PAIRS",
|
|
10539
|
+
help="Comma-separated key=Label pairs overriding project display "
|
|
10540
|
+
"labels (e.g. cctally-dev=Tracker). Display-only.",
|
|
10541
|
+
)
|
|
10213
10542
|
_add_ccusage_alias_args(p, ansi_emit=False)
|
|
10214
10543
|
_add_mode_arg(p)
|
|
10215
10544
|
_add_share_args(p)
|
|
@@ -10417,6 +10746,29 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
|
|
|
10417
10746
|
help="Display timezone: local, utc, or IANA name. "
|
|
10418
10747
|
"Overrides config display.tz for this call.",
|
|
10419
10748
|
)
|
|
10749
|
+
p.add_argument(
|
|
10750
|
+
"-a", "--active", action="store_true",
|
|
10751
|
+
help="Show only the active block, with burn-rate + projection "
|
|
10752
|
+
"(ccusage drop-in).",
|
|
10753
|
+
)
|
|
10754
|
+
p.add_argument(
|
|
10755
|
+
"-r", "--recent", action="store_true",
|
|
10756
|
+
help="Show only blocks from the last 3 days (plus the active block).",
|
|
10757
|
+
)
|
|
10758
|
+
p.add_argument(
|
|
10759
|
+
"-t", "--token-limit", dest="token_limit", default=None,
|
|
10760
|
+
metavar="N|max",
|
|
10761
|
+
help="Token limit for the quota %% column / projection warnings. "
|
|
10762
|
+
"An integer, or 'max' (default) to derive from the largest "
|
|
10763
|
+
"completed block.",
|
|
10764
|
+
)
|
|
10765
|
+
p.add_argument(
|
|
10766
|
+
"-n", "--session-length", dest="session_length", type=float,
|
|
10767
|
+
default=5.0, metavar="N",
|
|
10768
|
+
help="Accepted for ccusage drop-in compat; no-op — cctally blocks "
|
|
10769
|
+
"follow Anthropic's real 5-hour resets and are not re-sizable. "
|
|
10770
|
+
"A value <= 0 is rejected.",
|
|
10771
|
+
)
|
|
10420
10772
|
_add_ccusage_alias_args(p, ansi_emit=False)
|
|
10421
10773
|
_add_mode_arg(p)
|
|
10422
10774
|
p.set_defaults(func=cmd_blocks)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
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": {
|