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/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.19.0] - 2026-05-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`cctally blocks` (and `cctally claude blocks`) gain the ccusage drop-in flags `-a/--active`, `-r/--recent`, `-t/--token-limit N|max`, and `-n/--session-length N`.** `-a` filters to the single live block and renders it as a "Current Session Block Status" detail box — Block Started (+ "Xh Ym ago" / approximate-start `~` cue when the window has no recorded Anthropic reset), Time Remaining, Current Usage, Burn Rate and Projected Usage (when available), and — only when `-t` is passed explicitly — a Token Limit Status block whose `OK`/`WARNING`/`EXCEEDS LIMIT` color tracks the projected percent; with no active block it prints `No active session block found.` to stdout (JSON: `{"blocks": [], "message": "No active block"}`) and exits 0. `-r` keeps only blocks from the last 3 days plus the active block. `-t N` keys the table's `%`/REMAINING/PROJECTED surface (and the `-a` box's Token Limit Status) to an explicit limit even with no completed history, while `-t max` (the default) derives it from the largest completed block and prints `Using max tokens from previous sessions: N` to stdout (suppressed under `--json`); `--json` additionally gains an additive `tokenLimitStatus` key on active blocks under an explicit positive `-t`. `-n` is accepted for drop-in compatibility but is a no-op — cctally blocks follow Anthropic's real 5-hour resets and are not re-sizable — except `-n <= 0`, which errors (exit 1). cctally's block projection keeps its real-reset formula (a documented third intentional divergence from upstream's entry-span model). (#86)
|
|
12
|
+
|
|
13
|
+
## [1.18.0] - 2026-05-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Project-axis flags on `cctally daily` (and `cctally claude daily`): `-i/--instances`, `-p/--project`, and `--project-aliases`** — a drop-in for `ccusage daily`. `-i/--instances` groups the daily report by project (git-root), rendering a `Project: <label>` section per project with one global Total (`--json` becomes `{projects: {...}, totals}`); `-p/--project PATTERN` filters to matching projects by case-insensitive substring of the project label or path, and is repeatable with OR semantics; `--project-aliases key=Label,...` overrides project display labels (table headers only — never the JSON keys). Two distinct git-roots that share a basename stay separate (`app (work)` / `app (personal)`), null-project entries collect under `(unknown)`, and under `--format` shareable output `-i` is a no-op while `-p` is honored (the filter survives into the artifact). (#86)
|
|
17
|
+
|
|
8
18
|
## [1.17.0] - 2026-05-27
|
|
9
19
|
|
|
10
20
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -11,8 +11,8 @@ pipeline.
|
|
|
11
11
|
Holds:
|
|
12
12
|
- ``ProjectKey`` (frozen dataclass) + ``_resolve_project_key`` —
|
|
13
13
|
canonical project bucket identity for the ``project`` subcommand.
|
|
14
|
-
- ``
|
|
15
|
-
Codex JSONL discovery primitives.
|
|
14
|
+
- ``_discover_codex_session_files`` / ``_iter_codex_jsonl_paths`` —
|
|
15
|
+
Codex JSONL discovery primitives (multi-root $CODEX_HOME walk).
|
|
16
16
|
- ``IngestStats`` / ``CodexIngestStats`` (dataclasses), ``_progress_stderr``
|
|
17
17
|
/ ``_progress_codex_stderr`` — ingest progress + per-call telemetry.
|
|
18
18
|
- ``_ensure_session_files_row`` — idempotent backfill of
|
|
@@ -82,7 +82,7 @@ in the sibling graph):
|
|
|
82
82
|
``get_entries``, ``get_claude_session_entries``, ``get_codex_entries``,
|
|
83
83
|
``_resolve_project_key``, ``ProjectKey``, ``IngestStats``,
|
|
84
84
|
``CodexIngestStats``, ``_JoinedClaudeEntry``, ``_ensure_session_files_row``,
|
|
85
|
-
``_discover_codex_session_files``,
|
|
85
|
+
``_discover_codex_session_files``,
|
|
86
86
|
``cmd_cache_sync``, ``_progress_stderr``, ``_progress_codex_stderr``,
|
|
87
87
|
``_collect_entries_direct``, ``_collect_codex_entries_direct``,
|
|
88
88
|
``_direct_parse_claude_session_entries``, ``iter_codex_entries``)
|
|
@@ -107,7 +107,7 @@ import pathlib
|
|
|
107
107
|
import sqlite3
|
|
108
108
|
import sys
|
|
109
109
|
from dataclasses import dataclass, field
|
|
110
|
-
from typing import Any, Callable
|
|
110
|
+
from typing import Any, Callable, Iterator
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def _cctally():
|
|
@@ -265,27 +265,54 @@ def _resolve_project_key(
|
|
|
265
265
|
# === Region 2: Codex sessions-dir helpers (was bin/cctally:2072-2099) ===
|
|
266
266
|
|
|
267
267
|
|
|
268
|
-
def
|
|
269
|
-
"""
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
268
|
+
def _iter_codex_jsonl_paths(roots: list[pathlib.Path]) -> Iterator[pathlib.Path]:
|
|
269
|
+
"""Yield each existing *.jsonl under the given roots, de-duped by RESOLVED
|
|
270
|
+
path (first occurrence wins — collapses overlapping/prefix roots and
|
|
271
|
+
symlink/`..` aliases of the same physical file).
|
|
272
|
+
|
|
273
|
+
Pure read: globs + is_file() only, no DB access. Shared by both Codex
|
|
274
|
+
walkers (_discover_codex_session_files and sync_codex_cache) so they stay
|
|
275
|
+
in lock-step on dedup + is_file() ordering.
|
|
276
|
+
"""
|
|
277
|
+
seen: set[pathlib.Path] = set()
|
|
278
|
+
for root in roots:
|
|
279
|
+
for jp in root.glob("**/*.jsonl"):
|
|
280
|
+
# Dedup on the RESOLVED path, not the raw spelling. A symlinked
|
|
281
|
+
# $CODEX_HOME root or an alias entry (`.../.codex`,
|
|
282
|
+
# `.../sub/../.codex`) can glob the same physical file under
|
|
283
|
+
# different spellings; UNIQUE(source_path, line_offset) keys on the
|
|
284
|
+
# string, so distinct spellings would double-ingest (2-3x tokens /
|
|
285
|
+
# cost) on a fresh walk. resolve() collapses the aliases (issue
|
|
286
|
+
# #108). First spelling still wins for the yielded source_path.
|
|
287
|
+
try:
|
|
288
|
+
key = jp.resolve()
|
|
289
|
+
except OSError:
|
|
290
|
+
key = jp # unresolvable (broken symlink, perms) — key on raw
|
|
291
|
+
if key in seen:
|
|
292
|
+
continue
|
|
293
|
+
seen.add(key)
|
|
294
|
+
if jp.is_file():
|
|
295
|
+
yield jp
|
|
274
296
|
|
|
275
297
|
|
|
276
298
|
def _discover_codex_session_files(
|
|
277
299
|
range_start: dt.datetime,
|
|
278
300
|
) -> list[pathlib.Path]:
|
|
279
|
-
"""Glob
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
301
|
+
"""Glob each $CODEX_HOME session root's **/*.jsonl, mtime >= range_start.
|
|
302
|
+
|
|
303
|
+
Iterates _cctally()._codex_session_roots() (multi-root). The "none found"
|
|
304
|
+
notice fires ONLY when there are zero session-root directories at all (the
|
|
305
|
+
multi-root analogue of the old single-dir-missing check) — NOT when roots
|
|
306
|
+
exist but the mtime filter leaves the set empty (that stays silent, as
|
|
307
|
+
today, so narrow-range queries gain no new stderr).
|
|
308
|
+
"""
|
|
309
|
+
roots = _cctally()._codex_session_roots()
|
|
310
|
+
if not roots:
|
|
311
|
+
eprint("[codex] no Codex session directory found")
|
|
283
312
|
return []
|
|
284
313
|
start_ts = range_start.timestamp()
|
|
285
314
|
result: list[pathlib.Path] = []
|
|
286
|
-
for jp in
|
|
287
|
-
if not jp.is_file():
|
|
288
|
-
continue
|
|
315
|
+
for jp in _iter_codex_jsonl_paths(roots):
|
|
289
316
|
try:
|
|
290
317
|
mtime = jp.stat().st_mtime
|
|
291
318
|
except OSError:
|
|
@@ -937,6 +964,13 @@ class _JoinedClaudeEntry:
|
|
|
937
964
|
# reconciles with daily/range-cost paths that already pass
|
|
938
965
|
# `entry.cost_usd` into `_calculate_entry_cost`.
|
|
939
966
|
cost_usd: float | None = None
|
|
967
|
+
# Non-token `usage` extras (parsed `usage_extra_json`) — notably
|
|
968
|
+
# `speed`, which `_aggregate_buckets` reads to render `<model>-fast`.
|
|
969
|
+
# `iter_entries` merges these into its `UsageEntry.usage`; the joined
|
|
970
|
+
# path must carry them too so `_usage_entry_from_joined` can restore
|
|
971
|
+
# them (else `daily -i`/`-p` lose fast-tier model labels). None when
|
|
972
|
+
# the row has no extras.
|
|
973
|
+
usage_extra: dict | None = None
|
|
940
974
|
|
|
941
975
|
|
|
942
976
|
def get_claude_session_entries(
|
|
@@ -996,7 +1030,7 @@ def get_claude_session_entries(
|
|
|
996
1030
|
" se.cache_create_tokens, se.cache_read_tokens, "
|
|
997
1031
|
" se.source_path, "
|
|
998
1032
|
" sf.session_id, sf.project_path, "
|
|
999
|
-
" se.cost_usd_raw "
|
|
1033
|
+
" se.cost_usd_raw, se.usage_extra_json "
|
|
1000
1034
|
"FROM session_entries se "
|
|
1001
1035
|
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
1002
1036
|
"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
|
|
@@ -1024,6 +1058,7 @@ def get_claude_session_entries(
|
|
|
1024
1058
|
session_id=row[7],
|
|
1025
1059
|
project_path=row[8],
|
|
1026
1060
|
cost_usd=row[9],
|
|
1061
|
+
usage_extra=(json.loads(row[10]) if row[10] else None),
|
|
1027
1062
|
)
|
|
1028
1063
|
for row in rows
|
|
1029
1064
|
]
|
|
@@ -1135,9 +1170,16 @@ def _direct_parse_claude_session_entries(
|
|
|
1135
1170
|
results: list[_JoinedClaudeEntry] = []
|
|
1136
1171
|
flat: list[tuple[UsageEntry, str]] = list(dedupe_map.values()) + no_key_with_meta
|
|
1137
1172
|
flat.sort(key=lambda pair: pair[0].timestamp)
|
|
1173
|
+
_token_keys = {
|
|
1174
|
+
"input_tokens", "output_tokens",
|
|
1175
|
+
"cache_creation_input_tokens", "cache_read_input_tokens",
|
|
1176
|
+
}
|
|
1138
1177
|
for entry, source_path in flat:
|
|
1139
1178
|
usage = entry.usage
|
|
1140
1179
|
sid, cwd = meta_by_path[source_path]
|
|
1180
|
+
# Mirror the cache-backed path: carry non-token `usage` extras
|
|
1181
|
+
# (e.g. `speed`) so `_usage_entry_from_joined` can restore them.
|
|
1182
|
+
extras = {k: v for k, v in usage.items() if k not in _token_keys}
|
|
1141
1183
|
results.append(_JoinedClaudeEntry(
|
|
1142
1184
|
timestamp=entry.timestamp,
|
|
1143
1185
|
model=entry.model,
|
|
@@ -1153,6 +1195,7 @@ def _direct_parse_claude_session_entries(
|
|
|
1153
1195
|
session_id=sid,
|
|
1154
1196
|
project_path=cwd,
|
|
1155
1197
|
cost_usd=entry.cost_usd,
|
|
1198
|
+
usage_extra=(extras or None),
|
|
1156
1199
|
))
|
|
1157
1200
|
|
|
1158
1201
|
return results
|
|
@@ -1174,6 +1217,9 @@ class CodexIngestStats:
|
|
|
1174
1217
|
# ``IngestStats`` (Claude path) which carries an UPSERT and
|
|
1175
1218
|
# therefore counts both new INSERTs and DO UPDATE replacements.
|
|
1176
1219
|
rows_changed: int = 0
|
|
1220
|
+
# Count of cached files dropped because they fall outside the CURRENT
|
|
1221
|
+
# $CODEX_HOME root set (issue #108 — a prior-root purge, not a delta).
|
|
1222
|
+
files_pruned: int = 0
|
|
1177
1223
|
lock_contended: bool = False
|
|
1178
1224
|
|
|
1179
1225
|
|
|
@@ -1227,14 +1273,52 @@ def sync_codex_cache(
|
|
|
1227
1273
|
conn.commit()
|
|
1228
1274
|
eprint("[cache-sync] rebuild: cleared Codex cached entries")
|
|
1229
1275
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
if jp.is_file():
|
|
1235
|
-
paths.append(jp)
|
|
1276
|
+
roots = _cctally()._codex_session_roots()
|
|
1277
|
+
# Pure read (glob + is_file only); safe to run before the SELECT and
|
|
1278
|
+
# the per-file loop, where no cache.db write lock may be held.
|
|
1279
|
+
paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
|
|
1236
1280
|
stats.files_total = len(paths)
|
|
1237
1281
|
|
|
1282
|
+
# Scope the cache to the CURRENT root set: drop rows ingested under a
|
|
1283
|
+
# prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
|
|
1284
|
+
# predicate — it reads every row in range — so without this, reusing
|
|
1285
|
+
# the same cache.db across `CODEX_HOME=/A` then `CODEX_HOME=/B` runs
|
|
1286
|
+
# returns A+B instead of just B. Prune every real (absolute) row
|
|
1287
|
+
# outside the current set, even when that set is empty (an empty
|
|
1288
|
+
# current root then prunes the cache to empty): the cache is fully
|
|
1289
|
+
# re-derivable, so honoring the override beats retaining unreachable
|
|
1290
|
+
# rows. Done INSIDE the lock and committed BEFORE the existing-SELECT
|
|
1291
|
+
# + parse loop so no cache.db write lock is held across the read-heavy
|
|
1292
|
+
# ingest (same invariant as the --rebuild clear above). Concurrent
|
|
1293
|
+
# processes with different $CODEX_HOME would prune each other; the
|
|
1294
|
+
# flock serializes them and that is a pathological configuration.
|
|
1295
|
+
if not rebuild: # --rebuild already cleared both tables above
|
|
1296
|
+
current_paths = {str(p) for p in paths}
|
|
1297
|
+
# Only prune ABSOLUTE source_paths. _codex_home_roots() makes
|
|
1298
|
+
# every real root absolute (via .absolute()), so a real ingested
|
|
1299
|
+
# row always stores an absolute str(jp) — INCLUDING a relative
|
|
1300
|
+
# $CODEX_HOME like `./codexA`, which is canonicalized before the
|
|
1301
|
+
# glob. A relative path here is therefore — by construction — a
|
|
1302
|
+
# synthetic baked-cache fixture row (e.g. build-speed-fixtures.py)
|
|
1303
|
+
# with no on-disk JSONL to scope against; pruning it would wipe a
|
|
1304
|
+
# cache meant to be read as-is (issue #108).
|
|
1305
|
+
orphan_paths = [
|
|
1306
|
+
row[0]
|
|
1307
|
+
for row in conn.execute("SELECT path FROM codex_session_files")
|
|
1308
|
+
if row[0] not in current_paths and os.path.isabs(row[0])
|
|
1309
|
+
]
|
|
1310
|
+
if orphan_paths:
|
|
1311
|
+
conn.executemany(
|
|
1312
|
+
"DELETE FROM codex_session_entries WHERE source_path = ?",
|
|
1313
|
+
[(p,) for p in orphan_paths],
|
|
1314
|
+
)
|
|
1315
|
+
conn.executemany(
|
|
1316
|
+
"DELETE FROM codex_session_files WHERE path = ?",
|
|
1317
|
+
[(p,) for p in orphan_paths],
|
|
1318
|
+
)
|
|
1319
|
+
conn.commit()
|
|
1320
|
+
stats.files_pruned = len(orphan_paths)
|
|
1321
|
+
|
|
1238
1322
|
# This SELECT does NOT open an implicit transaction (Python's
|
|
1239
1323
|
# sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
|
|
1240
1324
|
# UPDATE/DELETE/REPLACE statement between here and the per-file
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -232,6 +232,41 @@ def _aggregate_monthly(
|
|
|
232
232
|
)
|
|
233
233
|
|
|
234
234
|
|
|
235
|
+
def _aggregate_daily_by_project(
|
|
236
|
+
keyed_entries: list[tuple[Any, UsageEntry]],
|
|
237
|
+
*,
|
|
238
|
+
tz: "Any | None" = None,
|
|
239
|
+
mode: str = "auto",
|
|
240
|
+
) -> list[tuple[Any, list[BucketUsage]]]:
|
|
241
|
+
"""Group ``(project_key, UsageEntry)`` pairs into per-project daily buckets.
|
|
242
|
+
|
|
243
|
+
Returns ``[(project_key, [BucketUsage date-asc]), ...]`` ordered by each
|
|
244
|
+
project's total cost descending, ties broken by ``project_key.display_key``
|
|
245
|
+
ascending. ``project_key`` is opaque/hashable (a ``ProjectKey``); resolution
|
|
246
|
+
happened in the caller, so this stays pure (no filesystem).
|
|
247
|
+
|
|
248
|
+
Reuses ``_aggregate_daily`` per group, so per-model breakdowns, token sums,
|
|
249
|
+
and ``mode``/``cost_usd`` threading are identical to the non-instances path.
|
|
250
|
+
"""
|
|
251
|
+
grouped: dict[Any, list[UsageEntry]] = {}
|
|
252
|
+
order: list[Any] = []
|
|
253
|
+
for key, entry in keyed_entries:
|
|
254
|
+
bucket = grouped.get(key)
|
|
255
|
+
if bucket is None:
|
|
256
|
+
grouped[key] = bucket = []
|
|
257
|
+
order.append(key)
|
|
258
|
+
bucket.append(entry)
|
|
259
|
+
|
|
260
|
+
ranked: list[tuple[Any, list[BucketUsage], float]] = []
|
|
261
|
+
for key in order:
|
|
262
|
+
buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
|
|
263
|
+
total = sum(b.cost_usd for b in buckets)
|
|
264
|
+
ranked.append((key, buckets, total))
|
|
265
|
+
|
|
266
|
+
ranked.sort(key=lambda t: (-t[2], t[0].display_key))
|
|
267
|
+
return [(key, buckets) for key, buckets, _ in ranked]
|
|
268
|
+
|
|
269
|
+
|
|
235
270
|
def _aggregate_weekly(
|
|
236
271
|
entries: list[UsageEntry],
|
|
237
272
|
weeks: list[SubWeek],
|
|
@@ -340,6 +375,14 @@ class CodexSessionUsage:
|
|
|
340
375
|
models: list[str]
|
|
341
376
|
model_breakdowns: list[dict[str, Any]]
|
|
342
377
|
last_activity: dt.datetime
|
|
378
|
+
# Issue #110: the matched $CODEX_HOME root in home-root form
|
|
379
|
+
# (e.g. "<root>/.codex", or "<root>" for a direct-JSONL root). Used ONLY
|
|
380
|
+
# to disambiguate the displayed / JSON label when two cross-root sessions
|
|
381
|
+
# share the same relative `session_id_path`. "" for the bare-relative
|
|
382
|
+
# fixture form (which cannot collide cross-root). Single-root data leaves
|
|
383
|
+
# every row's `codex_root` constant, so the renderers' collision check
|
|
384
|
+
# never fires and output stays byte-identical.
|
|
385
|
+
codex_root: str = ""
|
|
343
386
|
|
|
344
387
|
|
|
345
388
|
@dataclass
|
|
@@ -496,24 +539,30 @@ def _aggregate_codex_weekly(
|
|
|
496
539
|
def _session_path_parts(source_path: str) -> tuple[str, str, str]:
|
|
497
540
|
"""Return (session_id_path, session_file, directory) from a full path.
|
|
498
541
|
|
|
499
|
-
session_id_path = relative path under
|
|
500
|
-
stripped (e.g. "2025/12/25/rollout-...").
|
|
542
|
+
session_id_path = relative path under the matched $CODEX_HOME session
|
|
543
|
+
root with .jsonl stripped (e.g. "2025/12/25/rollout-...").
|
|
501
544
|
session_file = basename without .jsonl extension.
|
|
502
|
-
directory = relative parent path under
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
545
|
+
directory = relative parent path under the matched root.
|
|
546
|
+
|
|
547
|
+
Tries each root in _codex_session_roots() order (the same list/order the
|
|
548
|
+
discovery walkers use, so overlapping/prefix roots resolve to the FIRST
|
|
549
|
+
matching root deterministically); first relative_to() that succeeds wins.
|
|
550
|
+
Falls back to the bare-relative ".codex/sessions/<rest>" fixture form (the
|
|
551
|
+
shape emitted by build-codex-fixtures.py so committed fixture cache.db
|
|
552
|
+
files stay free of maintainer absolute paths), then basename. Direct-JSONL
|
|
553
|
+
roots yield an id relative to <entry> itself (no sessions/ prefix).
|
|
510
554
|
"""
|
|
511
|
-
|
|
555
|
+
roots = _cctally()._codex_session_roots()
|
|
512
556
|
p = pathlib.Path(source_path)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
557
|
+
rel: pathlib.PurePath | None = None
|
|
558
|
+
for root in roots:
|
|
559
|
+
try:
|
|
560
|
+
rel = p.relative_to(root)
|
|
561
|
+
break
|
|
562
|
+
except ValueError:
|
|
563
|
+
continue
|
|
564
|
+
if rel is None:
|
|
565
|
+
# Bare-relative ".codex/sessions/<rest>" (fixture form), else basename.
|
|
517
566
|
# Use PurePosixPath to avoid Windows-style drive parsing on unusual
|
|
518
567
|
# inputs; fixture-emitted paths are always POSIX.
|
|
519
568
|
parts = pathlib.PurePosixPath(source_path).parts
|
|
@@ -525,6 +574,22 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
|
|
|
525
574
|
return str(stem), stem.name, str(stem.parent)
|
|
526
575
|
|
|
527
576
|
|
|
577
|
+
def _codex_home_root_from_prefix(root_prefix: str) -> str:
|
|
578
|
+
"""Normalize the aggregator's `root_prefix` to the matched $CODEX_HOME entry.
|
|
579
|
+
|
|
580
|
+
`root_prefix` is `source_path` with the relative `id_path` tail removed, so a
|
|
581
|
+
Codex-home match looks like "<root>/.codex/sessions/" and a direct-JSONL
|
|
582
|
+
match like "<root>/". Strip the trailing slash and any "/sessions" tail to
|
|
583
|
+
recover the home root the user configured — the unit the issue #110
|
|
584
|
+
disambiguator labels by. The bare-relative fixture prefix ".codex/sessions/"
|
|
585
|
+
normalizes to ".codex" (constant across fixtures, so it never collides).
|
|
586
|
+
"""
|
|
587
|
+
s = root_prefix.rstrip("/")
|
|
588
|
+
if s.endswith("/sessions"):
|
|
589
|
+
s = s[: -len("/sessions")]
|
|
590
|
+
return s
|
|
591
|
+
|
|
592
|
+
|
|
528
593
|
def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
|
|
529
594
|
"""Group by session file path (upstream-compatible).
|
|
530
595
|
|
|
@@ -535,13 +600,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
535
600
|
Per-model breakdowns include `isFallback: bool` — true when the model is
|
|
536
601
|
absent from CODEX_MODEL_PRICING.
|
|
537
602
|
"""
|
|
538
|
-
by_session: dict[str, dict[str, Any]] = {}
|
|
603
|
+
by_session: dict[tuple[str, str], dict[str, Any]] = {}
|
|
539
604
|
for entry in entries:
|
|
540
605
|
id_path, file_name, directory = _session_path_parts(entry.source_path)
|
|
541
|
-
|
|
606
|
+
# Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
|
|
607
|
+
# roots (issue #108). _session_path_parts strips the matched root, so
|
|
608
|
+
# <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
|
|
609
|
+
# path under <rootB> both yield id_path "2026/04/17/rollout-x";
|
|
610
|
+
# grouping on id_path alone would silently merge two distinct sessions
|
|
611
|
+
# (summed tokens, one UUID). Key on (root_prefix, id_path), where
|
|
612
|
+
# root_prefix is source_path with the id_path tail removed. Single-root
|
|
613
|
+
# data — and the bare-relative fixture form — has a constant prefix, so
|
|
614
|
+
# the grouping, insertion order, and every golden stay byte-identical;
|
|
615
|
+
# only a genuine cross-root collision splits into separate rows.
|
|
616
|
+
suffix = id_path + ".jsonl"
|
|
617
|
+
sp = entry.source_path
|
|
618
|
+
root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
|
|
619
|
+
sess = by_session.setdefault((root_prefix, id_path), {
|
|
542
620
|
"session_id_uuid": entry.session_id,
|
|
621
|
+
"session_id_path": id_path,
|
|
543
622
|
"session_file": file_name,
|
|
544
623
|
"directory": directory,
|
|
624
|
+
# Matched $CODEX_HOME root (home-root form) — issue #110 display
|
|
625
|
+
# disambiguator. Derived from the same root_prefix that keys the
|
|
626
|
+
# group, so it's constant per group.
|
|
627
|
+
"codex_root": _codex_home_root_from_prefix(root_prefix),
|
|
545
628
|
"input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
|
|
546
629
|
"cost": 0.0, "models": {}, "models_order": [],
|
|
547
630
|
"last": entry.timestamp,
|
|
@@ -571,7 +654,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
571
654
|
sess["last"] = entry.timestamp
|
|
572
655
|
|
|
573
656
|
result: list[CodexSessionUsage] = []
|
|
574
|
-
for
|
|
657
|
+
for _group_key, s in by_session.items():
|
|
575
658
|
model_breakdowns = [
|
|
576
659
|
{
|
|
577
660
|
"modelName": model,
|
|
@@ -588,7 +671,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
588
671
|
model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
|
|
589
672
|
result.append(CodexSessionUsage(
|
|
590
673
|
session_id=s["session_id_uuid"],
|
|
591
|
-
session_id_path=
|
|
674
|
+
session_id_path=s["session_id_path"],
|
|
592
675
|
session_file=s["session_file"],
|
|
593
676
|
directory=s["directory"],
|
|
594
677
|
input_tokens=s["input"],
|
|
@@ -606,6 +689,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
606
689
|
models=list(s["models_order"]),
|
|
607
690
|
model_breakdowns=model_breakdowns,
|
|
608
691
|
last_activity=s["last"],
|
|
692
|
+
codex_root=s["codex_root"],
|
|
609
693
|
))
|
|
610
694
|
result.sort(key=lambda x: x.last_activity, reverse=True)
|
|
611
695
|
return result
|
package/bin/_lib_blocks.py
CHANGED
|
@@ -36,6 +36,7 @@ import bisect
|
|
|
36
36
|
import datetime as dt
|
|
37
37
|
import json
|
|
38
38
|
import pathlib
|
|
39
|
+
import re
|
|
39
40
|
import sys
|
|
40
41
|
from dataclasses import dataclass
|
|
41
42
|
from typing import Any
|
|
@@ -432,7 +433,42 @@ def _build_activity_block(
|
|
|
432
433
|
)
|
|
433
434
|
|
|
434
435
|
|
|
435
|
-
def
|
|
436
|
+
def _max_completed_block_tokens(blocks: list["Block"]) -> int:
|
|
437
|
+
"""Largest total_tokens among completed (non-gap, non-active) blocks.
|
|
438
|
+
|
|
439
|
+
The auto-derived token-limit baseline for the `%`/REMAINING/PROJECTED
|
|
440
|
+
surface — matches ccusage's `maxTokensFromAll` (computed over all blocks
|
|
441
|
+
before any --recent/--active filtering). Returns 0 when there is no
|
|
442
|
+
completed block with tokens.
|
|
443
|
+
"""
|
|
444
|
+
best = 0
|
|
445
|
+
for b in blocks:
|
|
446
|
+
if not b.is_gap and not b.is_active and b.total_tokens > best:
|
|
447
|
+
best = b.total_tokens
|
|
448
|
+
return best
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _parse_blocks_token_limit(
|
|
452
|
+
raw: "str | None", max_from_completed: int
|
|
453
|
+
) -> "int | None":
|
|
454
|
+
"""Resolve the `-t/--token-limit` value to an int limit or None.
|
|
455
|
+
|
|
456
|
+
Mirrors ccusage `parseTokenLimit`: `None`/`""`/`"max"` → the auto-derived
|
|
457
|
+
`max_from_completed` (or None when it is 0); otherwise replicate JS
|
|
458
|
+
`Number.parseInt(raw, 10)` — leading optional sign + run of digits, stop at
|
|
459
|
+
the first non-digit (`"123abc"`→123, `"12.5"`→12, `"abc"`/`""`→None). A
|
|
460
|
+
non-positive result still returns the int; the caller's `limit > 0` gate
|
|
461
|
+
suppresses the `%` column (same observable result as upstream).
|
|
462
|
+
"""
|
|
463
|
+
if raw is None or raw in ("", "max"):
|
|
464
|
+
return max_from_completed if max_from_completed > 0 else None
|
|
465
|
+
m = re.match(r"\s*([+-]?\d+)", raw)
|
|
466
|
+
return int(m.group(1)) if m else None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _blocks_to_json(
|
|
470
|
+
blocks: list[Block], *, token_limit_status_limit: int | None = None
|
|
471
|
+
) -> str:
|
|
436
472
|
"""Serialize blocks to JSON matching upstream ccusage's output structure."""
|
|
437
473
|
|
|
438
474
|
def _iso_utc(ts: dt.datetime) -> str:
|
|
@@ -470,6 +506,24 @@ def _blocks_to_json(blocks: list[Block]) -> str:
|
|
|
470
506
|
"burnRate": block.burn_rate,
|
|
471
507
|
"projection": block.projection,
|
|
472
508
|
})
|
|
509
|
+
if (token_limit_status_limit is not None
|
|
510
|
+
and token_limit_status_limit > 0
|
|
511
|
+
and not block.is_gap
|
|
512
|
+
and block.is_active and block.projection):
|
|
513
|
+
limit = token_limit_status_limit
|
|
514
|
+
proj_tokens = block.projection["totalTokens"]
|
|
515
|
+
pct = (proj_tokens / limit) * 100.0
|
|
516
|
+
# Keep the exceeds/warning/ok thresholds (>100% / >80%) in sync with
|
|
517
|
+
# the box status ladder in _lib_render._render_active_block_box.
|
|
518
|
+
status = ("exceeds" if proj_tokens > limit
|
|
519
|
+
else "warning" if proj_tokens > limit * 0.8
|
|
520
|
+
else "ok")
|
|
521
|
+
obj["tokenLimitStatus"] = {
|
|
522
|
+
"limit": limit,
|
|
523
|
+
"projectedUsage": proj_tokens,
|
|
524
|
+
"percentUsed": pct,
|
|
525
|
+
"status": status,
|
|
526
|
+
}
|
|
473
527
|
result.append(obj)
|
|
474
528
|
|
|
475
529
|
return json.dumps({"blocks": result}, indent=2)
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -530,7 +530,7 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
|
530
530
|
if count == 0 and not s.codex_jsonl_present:
|
|
531
531
|
return CheckResult(
|
|
532
532
|
id="data.codex_cache", title="Codex cache",
|
|
533
|
-
severity="ok", summary="none (no
|
|
533
|
+
severity="ok", summary="none (no Codex session JSONL found)",
|
|
534
534
|
remediation=None,
|
|
535
535
|
details={"entries": 0, "codex_jsonl_present": False},
|
|
536
536
|
)
|