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 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
@@ -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
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
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
- rows,
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
- sql += " ORDER BY timestamp_utc ASC"
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
- sql += " ORDER BY se.timestamp_utc ASC"
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
 
@@ -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():