cctally 1.61.0 → 1.63.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 +15 -0
- package/bin/_cctally_cache.py +156 -6
- package/bin/_cctally_config.py +26 -0
- package/bin/_cctally_dashboard.py +670 -162
- package/bin/_cctally_db.py +27 -1
- package/bin/_cctally_parser.py +15 -0
- package/bin/_cctally_statusline.py +9 -1
- package/bin/_cctally_tui.py +74 -9
- package/bin/_cctally_weekrefs.py +9 -0
- package/bin/_lib_aggregators.py +98 -71
- package/bin/_lib_cache_report.py +455 -67
- package/bin/_lib_pricing.py +31 -4
- package/bin/_lib_snapshot_cache.py +869 -60
- package/bin/_lib_statusline.py +25 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.63.0] - 2026-07-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `cctally statusline --usage-only` renders just the subscription usage chip (`5h X% · 7d Y%`), dropping the model, cost, burn-rate, context, and reset-countdown segments — for users who want a small quota indicator in their prompt without the cost telemetry. Persist it with `cctally config set statusline.usage_only true`; `--no-usage-only` forces the full statusline when the config key is on. Contributed by @nathanm4 (public PR #3).
|
|
12
|
+
|
|
13
|
+
## [1.62.0] - 2026-07-06
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Dashboard warm rebuilds are materially faster on very large histories: the Daily, Weekly, Monthly, and Projects panels now fold only new activity into the current period each refresh instead of re-summing the whole open period, a `record-usage` write no longer forces a full projects rebuild, and on weeks with an in-place credit the pre-credit segment of the Weekly panel is now computed once and reused instead of being re-summed on every refresh. Output is unchanged (#271).
|
|
17
|
+
- The dashboard's cache-report card is now the last panel to join the warm-refresh fast path: each closed day's cache-savings breakdown is computed once and reused, so a live refresh recomputes only today instead of re-reading and re-folding the whole 14-day window every time — a large chunk of the remaining per-refresh work on big histories. This holds even when the trailing two weeks contain a day with no activity (a weekend or a day off): a quiet day is remembered as quiet, so it no longer forces the card back onto the slow full-window path on every refresh. The card's per-project net-savings values shift by a negligible floating-point amount as a side effect of the order-independent fold this relies on; everything else is unchanged (#272).
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- The live dashboard no longer serves a stale value for a past day, week, or month after a streaming message that began in that period finalizes after the period has rolled over — an in-place finalization that reused the same cache row previously slipped the idle-path snapshot check, so the closed period could keep showing its pre-finalization total until the next real change; the dashboard now recomputes the affected period (and no longer double-counts the current period's open bucket in the same situation) (#270).
|
|
21
|
+
- The live dashboard's memory footprint no longer creeps upward by a tiny amount for each day it stays running: the cache-report card's per-day cache (added in #272) now drops days that have rolled out of its trailing two-week window instead of holding onto them until the next restart. Output is unchanged (#275).
|
|
22
|
+
|
|
8
23
|
## [1.61.0] - 2026-07-05
|
|
9
24
|
|
|
10
25
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -805,6 +805,34 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
|
|
|
805
805
|
lock_fh.close()
|
|
806
806
|
|
|
807
807
|
|
|
808
|
+
def _bump_mutation_seq(conn: sqlite3.Connection) -> int:
|
|
809
|
+
"""Atomically increment the ``session_entries`` mutation counter in
|
|
810
|
+
``cache_meta`` and return the new value (#270, spec §6).
|
|
811
|
+
|
|
812
|
+
The counter (key ``session_entries_mutation_seq``) is a monotonic integer
|
|
813
|
+
stamped onto every insert and every WHERE-passing in-place UPSERT so an
|
|
814
|
+
id-stable finalization advances the composite dispatch signature (via the
|
|
815
|
+
``entry_mutation_seq`` leg) and the change-aware watermark, closing the
|
|
816
|
+
dashboard idle-path stale-snapshot hole.
|
|
817
|
+
|
|
818
|
+
``value`` has TEXT affinity; ``CAST(... AS INTEGER) + 1`` yields an integer
|
|
819
|
+
stored back as text, and ``RETURNING value`` returns that text form so
|
|
820
|
+
``int(...)`` is correct. Called PER FILE inside ``sync_cache``'s per-file
|
|
821
|
+
write transaction (rollback-safe: a file that rolls back reverts the counter
|
|
822
|
+
and its row stamps together), under the single-writer ``cache.db.lock``
|
|
823
|
+
flock, so no concurrency guard beyond the flock is needed. ``cache_meta`` is
|
|
824
|
+
guaranteed present by ``_apply_cache_schema`` before any ``sync_cache`` runs.
|
|
825
|
+
"""
|
|
826
|
+
row = conn.execute(
|
|
827
|
+
"INSERT INTO cache_meta(key, value) "
|
|
828
|
+
"VALUES ('session_entries_mutation_seq', '1') "
|
|
829
|
+
"ON CONFLICT(key) DO UPDATE SET "
|
|
830
|
+
" value = CAST(cache_meta.value AS INTEGER) + 1 "
|
|
831
|
+
"RETURNING value"
|
|
832
|
+
).fetchone()
|
|
833
|
+
return int(row[0])
|
|
834
|
+
|
|
835
|
+
|
|
808
836
|
def sync_cache(
|
|
809
837
|
conn: sqlite3.Connection,
|
|
810
838
|
*,
|
|
@@ -1493,6 +1521,18 @@ def sync_cache(
|
|
|
1493
1521
|
)
|
|
1494
1522
|
stats.files_reset_truncated += 1
|
|
1495
1523
|
if rows:
|
|
1524
|
+
# #270: bump the per-file mutation counter BEFORE capturing
|
|
1525
|
+
# `before`, so this cache_meta write stays OUTSIDE the
|
|
1526
|
+
# [before, after] total_changes window and never inflates
|
|
1527
|
+
# `stats.rows_changed` (byte-identity). Per file (not once
|
|
1528
|
+
# per sync) for rollback-safety: the counter write is atomic
|
|
1529
|
+
# with the row stamps in this file's write transaction, so a
|
|
1530
|
+
# file that rolls back reverts both together (spec §6). Each
|
|
1531
|
+
# row built for this file is stamped mutation_seq = this
|
|
1532
|
+
# file's `sync_seq` and mutation_min_ts = its own
|
|
1533
|
+
# timestamp_utc (== the event time on insert).
|
|
1534
|
+
sync_seq = _bump_mutation_seq(conn)
|
|
1535
|
+
stamped_rows = [r + (sync_seq, r[2]) for r in rows]
|
|
1496
1536
|
before = conn.total_changes
|
|
1497
1537
|
# ccusage-parity ON CONFLICT DO UPDATE: higher-token total
|
|
1498
1538
|
# wins on conflict; speed-set breaks ties. The partial
|
|
@@ -1522,8 +1562,9 @@ def sync_cache(
|
|
|
1522
1562
|
(source_path, line_offset, timestamp_utc, model,
|
|
1523
1563
|
msg_id, req_id, input_tokens, output_tokens,
|
|
1524
1564
|
cache_create_tokens, cache_read_tokens,
|
|
1525
|
-
usage_extra_json, speed, cost_usd_raw
|
|
1526
|
-
|
|
1565
|
+
usage_extra_json, speed, cost_usd_raw,
|
|
1566
|
+
mutation_seq, mutation_min_ts)
|
|
1567
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
1527
1568
|
ON CONFLICT(msg_id, req_id)
|
|
1528
1569
|
WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
|
|
1529
1570
|
DO UPDATE SET
|
|
@@ -1535,7 +1576,32 @@ def sync_cache(
|
|
|
1535
1576
|
cache_read_tokens = excluded.cache_read_tokens,
|
|
1536
1577
|
usage_extra_json = excluded.usage_extra_json,
|
|
1537
1578
|
speed = excluded.speed,
|
|
1538
|
-
cost_usd_raw = excluded.cost_usd_raw
|
|
1579
|
+
cost_usd_raw = excluded.cost_usd_raw,
|
|
1580
|
+
-- #270: stamp the change. mutation_seq advances
|
|
1581
|
+
-- exactly when this guarded UPSERT's WHERE passes
|
|
1582
|
+
-- (incl. the equal-tokens speed-tiebreak branch,
|
|
1583
|
+
-- Codex-2d). mutation_min_ts accumulates the
|
|
1584
|
+
-- EARLIEST event time the row has held —
|
|
1585
|
+
-- session_entries.mutation_min_ts is the OLD
|
|
1586
|
+
-- (pre-update) value, excluded.timestamp_utc the
|
|
1587
|
+
-- finalization's new time — so a finalization
|
|
1588
|
+
-- that moves the row across a bucket boundary
|
|
1589
|
+
-- still lets the closed-bucket watermark reach
|
|
1590
|
+
-- the OLD bucket (spec §6/§7b). The SET reads
|
|
1591
|
+
-- pre-update column values, unaffected by the
|
|
1592
|
+
-- sibling timestamp_utc = excluded.timestamp_utc.
|
|
1593
|
+
-- COALESCE(mutation_min_ts, timestamp_utc) guards
|
|
1594
|
+
-- a LEGACY row (written before these columns
|
|
1595
|
+
-- existed: mutation_min_ts NULL): SQLite scalar
|
|
1596
|
+
-- MIN(NULL, x) is NULL, which would strand the
|
|
1597
|
+
-- watermark; the pre-update timestamp_utc is that
|
|
1598
|
+
-- legacy row's old event time, so both its old
|
|
1599
|
+
-- and new buckets stay reachable. No-op for
|
|
1600
|
+
-- non-legacy rows (mutation_min_ts already set).
|
|
1601
|
+
mutation_seq = excluded.mutation_seq,
|
|
1602
|
+
mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
|
|
1603
|
+
session_entries.timestamp_utc),
|
|
1604
|
+
excluded.timestamp_utc)
|
|
1539
1605
|
WHERE
|
|
1540
1606
|
(excluded.input_tokens + excluded.output_tokens
|
|
1541
1607
|
+ excluded.cache_create_tokens + excluded.cache_read_tokens)
|
|
@@ -1551,7 +1617,7 @@ def sync_cache(
|
|
|
1551
1617
|
AND excluded.speed IS NOT NULL
|
|
1552
1618
|
AND session_entries.speed IS NULL
|
|
1553
1619
|
)""",
|
|
1554
|
-
|
|
1620
|
+
stamped_rows,
|
|
1555
1621
|
)
|
|
1556
1622
|
stats.rows_changed += conn.total_changes - before
|
|
1557
1623
|
# Conversation message ingest (Plan 1). Lands in the SAME
|
|
@@ -2302,7 +2368,14 @@ def iter_entries(
|
|
|
2302
2368
|
)
|
|
2303
2369
|
sql += r" AND source_path LIKE ? ESCAPE '\'"
|
|
2304
2370
|
params.append(f"%/projects/{escaped}/%")
|
|
2305
|
-
|
|
2371
|
+
# Explicit (timestamp_utc, id) tie-break (#271 §5 / Codex-3): the
|
|
2372
|
+
# `idx_entries_timestamp` index already stores keys as (timestamp_utc,
|
|
2373
|
+
# rowid), so an index-driven walk yields exactly this order and pinning it
|
|
2374
|
+
# is free at runtime (goldens unchanged). The pin converts that OBSERVED
|
|
2375
|
+
# planner behavior into a CONTRACT — a guaranteed total fold order — which
|
|
2376
|
+
# is what makes #271's incremental current-bucket append provably
|
|
2377
|
+
# byte-identical to the full-pass fold.
|
|
2378
|
+
sql += " ORDER BY timestamp_utc ASC, id ASC"
|
|
2306
2379
|
|
|
2307
2380
|
entries: list[UsageEntry] = []
|
|
2308
2381
|
for row in conn.execute(sql, params):
|
|
@@ -2328,6 +2401,74 @@ def iter_entries(
|
|
|
2328
2401
|
return entries
|
|
2329
2402
|
|
|
2330
2403
|
|
|
2404
|
+
def iter_entries_with_id(
|
|
2405
|
+
conn: sqlite3.Connection,
|
|
2406
|
+
range_start: dt.datetime,
|
|
2407
|
+
range_end: dt.datetime,
|
|
2408
|
+
*,
|
|
2409
|
+
after_seq: int | None = None,
|
|
2410
|
+
after_ts: dt.datetime | None = None,
|
|
2411
|
+
) -> list[tuple[int, UsageEntry]]:
|
|
2412
|
+
"""Like ``iter_entries`` but yields ``(id, UsageEntry)`` rows, ordered
|
|
2413
|
+
``(timestamp_utc, id)``, for #271's current-bucket accumulator (§7d).
|
|
2414
|
+
|
|
2415
|
+
When ``after_seq`` / ``after_ts`` are given, restricts to the incremental
|
|
2416
|
+
delta ``(mutation_seq > after_seq OR timestamp_utc > after_ts)`` — the
|
|
2417
|
+
``mutation_seq`` leg (#270 §8) catches genuinely-new ingests AND id-stable
|
|
2418
|
+
in-place finalizations (which advance ``mutation_seq`` while leaving ``id``
|
|
2419
|
+
flat, so the pre-#270 ``id > after_id`` leg missed them and double-counted);
|
|
2420
|
+
the ``timestamp_utc`` leg catches already-ingested rows that newly entered
|
|
2421
|
+
the window because ``now`` advanced (Codex-1). Both disjuncts stay
|
|
2422
|
+
index-usable (``idx_entries_mutation_seq`` range + ``idx_entries_timestamp``
|
|
2423
|
+
range), so the delta is O(delta), not a full current-bucket scan (an
|
|
2424
|
+
``EXPLAIN QUERY PLAN`` regression test guards this, Codex-2). On a
|
|
2425
|
+
pure-insert interval ``{mutation_seq > after_seq}`` == ``{id > after_id}``
|
|
2426
|
+
(each insert carries a fresh seq monotone with id), so the delta row set is
|
|
2427
|
+
byte-identical to the old ``id`` leg (§7b). The ``id`` is still SELECTed (the
|
|
2428
|
+
accumulator's ``id <= reconciled_max_id`` pre-existing-row cold-refold trigger
|
|
2429
|
+
reads it). ``iter_entries``' public ``list[UsageEntry]`` shape and
|
|
2430
|
+
``UsageEntry`` (which has no ``id`` field) are left untouched — this is a
|
|
2431
|
+
thin internal sibling, not an overload (Codex-5).
|
|
2432
|
+
"""
|
|
2433
|
+
start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
|
|
2434
|
+
end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
|
|
2435
|
+
sql = (
|
|
2436
|
+
"SELECT id, timestamp_utc, model, input_tokens, output_tokens, "
|
|
2437
|
+
"cache_create_tokens, cache_read_tokens, speed, cost_usd_raw, source_path "
|
|
2438
|
+
"FROM session_entries "
|
|
2439
|
+
"WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
|
|
2440
|
+
)
|
|
2441
|
+
params: list[Any] = [start_iso, end_iso]
|
|
2442
|
+
if after_seq is not None or after_ts is not None:
|
|
2443
|
+
after_seq_val = -1 if after_seq is None else int(after_seq)
|
|
2444
|
+
after_ts_val = (
|
|
2445
|
+
"" if after_ts is None
|
|
2446
|
+
else after_ts.astimezone(dt.timezone.utc).isoformat()
|
|
2447
|
+
)
|
|
2448
|
+
sql += " AND (mutation_seq > ? OR timestamp_utc > ?)"
|
|
2449
|
+
params += [after_seq_val, after_ts_val]
|
|
2450
|
+
sql += " ORDER BY timestamp_utc ASC, id ASC"
|
|
2451
|
+
|
|
2452
|
+
out: list[tuple[int, UsageEntry]] = []
|
|
2453
|
+
for row in conn.execute(sql, params):
|
|
2454
|
+
usage: dict[str, Any] = {
|
|
2455
|
+
"input_tokens": row[3],
|
|
2456
|
+
"output_tokens": row[4],
|
|
2457
|
+
"cache_creation_input_tokens": row[5],
|
|
2458
|
+
"cache_read_input_tokens": row[6],
|
|
2459
|
+
}
|
|
2460
|
+
if row[7] is not None: # speed (materialized column, #181)
|
|
2461
|
+
usage["speed"] = row[7]
|
|
2462
|
+
out.append((row[0], UsageEntry(
|
|
2463
|
+
timestamp=dt.datetime.fromisoformat(row[1]),
|
|
2464
|
+
model=row[2],
|
|
2465
|
+
usage=usage,
|
|
2466
|
+
cost_usd=row[8],
|
|
2467
|
+
source_path=row[9],
|
|
2468
|
+
)))
|
|
2469
|
+
return out
|
|
2470
|
+
|
|
2471
|
+
|
|
2331
2472
|
def _collect_entries_direct(
|
|
2332
2473
|
range_start: dt.datetime,
|
|
2333
2474
|
range_end: dt.datetime,
|
|
@@ -2462,7 +2603,16 @@ def get_claude_session_entries(
|
|
|
2462
2603
|
)
|
|
2463
2604
|
sql += r" AND se.source_path LIKE ? ESCAPE '\'"
|
|
2464
2605
|
params.append(f"%/projects/{escaped}/%")
|
|
2465
|
-
|
|
2606
|
+
# Explicit (timestamp_utc, id) tie-break (#275) — the same contract #271 §5
|
|
2607
|
+
# pinned on `get_entries` (see the twin ORDER BY above). `id` is the rowid, so
|
|
2608
|
+
# against `idx_entries_timestamp` (which stores keys as (timestamp_utc, rowid))
|
|
2609
|
+
# this is free at runtime and byte-identical to today's observed order. Pinning
|
|
2610
|
+
# it makes the fold order a total, plan-INDEPENDENT contract: the #272 warm path
|
|
2611
|
+
# folds today over a narrow `[today_start, now]` query while the cold path folds
|
|
2612
|
+
# over the full `[since, now]` query, and both — plus the `+=` day-row fold and
|
|
2613
|
+
# the by_project partials — must agree on equal-timestamp rows regardless of
|
|
2614
|
+
# which plan SQLite picks for either window.
|
|
2615
|
+
sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
|
|
2466
2616
|
|
|
2467
2617
|
rows = conn.execute(sql, params).fetchall()
|
|
2468
2618
|
|
package/bin/_cctally_config.py
CHANGED
|
@@ -309,6 +309,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
309
309
|
"statusline.visual_burn_rate",
|
|
310
310
|
"statusline.cost_source",
|
|
311
311
|
"statusline.cctally_extensions",
|
|
312
|
+
"statusline.usage_only",
|
|
312
313
|
"budget.weekly_usd",
|
|
313
314
|
"budget.alerts_enabled",
|
|
314
315
|
"budget.alert_thresholds",
|
|
@@ -379,6 +380,25 @@ def _validate_statusline_cctally_extensions(value):
|
|
|
379
380
|
)
|
|
380
381
|
|
|
381
382
|
|
|
383
|
+
def _validate_statusline_usage_only(value):
|
|
384
|
+
"""Validate ``statusline.usage_only``.
|
|
385
|
+
|
|
386
|
+
Accepts booleans (preferred) or canonical truthy/falsy strings
|
|
387
|
+
(``true``/``false``/``yes``/``no``/``on``/``off``/``1``/``0``).
|
|
388
|
+
"""
|
|
389
|
+
if isinstance(value, bool):
|
|
390
|
+
return value
|
|
391
|
+
if isinstance(value, str):
|
|
392
|
+
lo = value.strip().lower()
|
|
393
|
+
if lo in ("true", "yes", "on", "1"):
|
|
394
|
+
return True
|
|
395
|
+
if lo in ("false", "no", "off", "0"):
|
|
396
|
+
return False
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"statusline.usage_only must be boolean (got {value!r})"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
382
402
|
def cmd_config(args: argparse.Namespace) -> int:
|
|
383
403
|
"""Get/set/unset persisted user preferences in config.json.
|
|
384
404
|
|
|
@@ -544,6 +564,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
544
564
|
"statusline.visual_burn_rate",
|
|
545
565
|
"statusline.cost_source",
|
|
546
566
|
"statusline.cctally_extensions",
|
|
567
|
+
"statusline.usage_only",
|
|
547
568
|
):
|
|
548
569
|
sl_block = config.get("statusline") if isinstance(config, dict) else None
|
|
549
570
|
if not isinstance(sl_block, dict):
|
|
@@ -554,6 +575,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
554
575
|
"visual_burn_rate": "off",
|
|
555
576
|
"cost_source": "auto",
|
|
556
577
|
"cctally_extensions": True,
|
|
578
|
+
"usage_only": False,
|
|
557
579
|
}
|
|
558
580
|
if stored is None:
|
|
559
581
|
return defaults[inner]
|
|
@@ -561,6 +583,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
561
583
|
"visual_burn_rate": _validate_statusline_visual_burn_rate,
|
|
562
584
|
"cost_source": _validate_statusline_cost_source,
|
|
563
585
|
"cctally_extensions": _validate_statusline_cctally_extensions,
|
|
586
|
+
"usage_only": _validate_statusline_usage_only,
|
|
564
587
|
}[inner]
|
|
565
588
|
try:
|
|
566
589
|
return validator(stored)
|
|
@@ -987,12 +1010,14 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
987
1010
|
"statusline.visual_burn_rate",
|
|
988
1011
|
"statusline.cost_source",
|
|
989
1012
|
"statusline.cctally_extensions",
|
|
1013
|
+
"statusline.usage_only",
|
|
990
1014
|
):
|
|
991
1015
|
inner_key = key.split(".", 1)[1]
|
|
992
1016
|
validator = {
|
|
993
1017
|
"visual_burn_rate": _validate_statusline_visual_burn_rate,
|
|
994
1018
|
"cost_source": _validate_statusline_cost_source,
|
|
995
1019
|
"cctally_extensions": _validate_statusline_cctally_extensions,
|
|
1020
|
+
"usage_only": _validate_statusline_usage_only,
|
|
996
1021
|
}[inner_key]
|
|
997
1022
|
try:
|
|
998
1023
|
normalized = validator(raw)
|
|
@@ -1390,6 +1415,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1390
1415
|
"statusline.visual_burn_rate",
|
|
1391
1416
|
"statusline.cost_source",
|
|
1392
1417
|
"statusline.cctally_extensions",
|
|
1418
|
+
"statusline.usage_only",
|
|
1393
1419
|
):
|
|
1394
1420
|
inner_key = key.split(".", 1)[1]
|
|
1395
1421
|
with config_writer_lock():
|