cctally 1.44.3 → 1.46.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,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.46.0] - 2026-06-15
9
+
10
+ ### Added
11
+ - Conversation viewer: the open reader now live-tails an active conversation in near-real time — new turns appear within about a second of the session's file changing (instead of waiting for the periodic 5-second dashboard tick) via a dedicated per-conversation SSE stream that watches only the open session's transcript; the live-tail is on by default and can be turned off with the new `dashboard.live_tail` config key (or the dashboard's Settings → "Conversation viewer" toggle), and `--no-sync` keeps it passive so frozen-data debugging is unaffected.
12
+
13
+ ## [1.45.0] - 2026-06-15
14
+
15
+ ### Added
16
+ - Conversation viewer: the dashboard now marks any assistant turn that suffered a **prompt-cache failure** — where Claude re-created the bulk of its cached prompt prefix instead of reading it (most often on the first turn after you send a new message — usually well inside the cache TTL, not just on long idle gaps), re-billing those tokens at the higher cache-write rate. The failing turn gets an amber `⚡ CACHE REBUILT · <tokens> · +$<wasted>` chip in its header, and the conversation outline gains a matching amber landmark, a `⚡` quick-jump button (`c` / `C` keys), and a "Cache" count in the at-a-glance stats card. Detection is conservative (per model + thread: `cache_read` collapses below half its running high-water mark **and** ≥75% of the turn's context is freshly re-created, with the unavoidable first cache prime, model switches, and context compaction all excluded), so a typical session shows about one marker. The markers are on by default and can be turned off via the new `dashboard.cache_failure_markers` config key (or the dashboard's Settings → "Conversation viewer" toggle). The wasted-cost figure is a display-only estimate recomputed per read — no new database tables and no migration.
17
+
8
18
  ## [1.44.3] - 2026-06-15
9
19
 
10
20
  ### Fixed
@@ -465,6 +465,20 @@ class IngestStats:
465
465
  # new inserts.
466
466
  rows_changed: int = 0
467
467
  lock_contended: bool = False
468
+ # Targeted (only_paths) live-tail fast-path fields. Default-clean so the
469
+ # only_paths=None callers (every existing caller) read targeted_clean=True
470
+ # and are otherwise unaffected.
471
+ files_failed: int = 0
472
+ deferred_reason: "str | None" = None
473
+
474
+ @property
475
+ def targeted_clean(self) -> bool:
476
+ """True ⇔ a targeted ingest fully applied: not contended, not deferred,
477
+ and no per-file failure. The watch loop emits + advances `seen` only
478
+ when this is True."""
479
+ return (not self.lock_contended
480
+ and self.deferred_reason is None
481
+ and self.files_failed == 0)
468
482
 
469
483
 
470
484
  def _progress_stderr(stats: IngestStats, *, force: bool = False) -> None:
@@ -563,11 +577,43 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
563
577
  conn.commit()
564
578
 
565
579
 
580
+ # Flags whose presence means the cache is mid-migration / mid-reingest. A
581
+ # targeted (only_paths) ingest DECLINES when any is set and defers to the next
582
+ # full background sync — inserting through a half-migrated FTS shape or skipping
583
+ # a pending backfill would diverge from what a full sync produces (spec §
584
+ # "Targeted ingest contract"). Enumerated against the flag-consumption blocks
585
+ # guarded by the `if not rebuild and not targeted:` branch in sync_cache (the
586
+ # full-sync-only `_consume_*` calls); keep this tuple in sync with those.
587
+ _TARGETED_DECLINE_FLAGS = (
588
+ "conversation_backfill_pending",
589
+ "ai_titles_backfill_pending",
590
+ "conversation_reingest_pending",
591
+ "conversation_source_tool_use_reingest_pending",
592
+ "conversation_reingest_enrichment_pending",
593
+ "conversation_media_reingest_pending",
594
+ "conversation_search_split_pending",
595
+ "conversation_promote_command_args_pending",
596
+ "conversation_sessions_backfill_pending",
597
+ )
598
+
599
+
600
+ def _targeted_has_pending_global_work(conn) -> bool:
601
+ placeholders = ",".join("?" for _ in _TARGETED_DECLINE_FLAGS)
602
+ try:
603
+ row = conn.execute(
604
+ f"SELECT 1 FROM cache_meta WHERE key IN ({placeholders}) LIMIT 1",
605
+ _TARGETED_DECLINE_FLAGS).fetchone()
606
+ except sqlite3.OperationalError:
607
+ return False
608
+ return row is not None
609
+
610
+
566
611
  def sync_cache(
567
612
  conn: sqlite3.Connection,
568
613
  *,
569
614
  progress: Callable[[IngestStats], None] | None = None,
570
615
  rebuild: bool = False,
616
+ only_paths: "set[str] | None" = None,
571
617
  ) -> IngestStats:
572
618
  """Read-through delta ingest. Acquires an exclusive fcntl.flock; if
573
619
  another process holds it, returns immediately with lock_contended=True
@@ -592,6 +638,14 @@ def sync_cache(
592
638
  stats.lock_contended = True
593
639
  return stats
594
640
 
641
+ targeted = only_paths is not None
642
+ if targeted:
643
+ if rebuild:
644
+ raise ValueError("sync_cache: only_paths is incompatible with rebuild")
645
+ if _targeted_has_pending_global_work(conn):
646
+ stats.deferred_reason = "pending_global_flags"
647
+ return stats
648
+
595
649
  # Walk-complete sentinel gating (cctally-dev#93, D5b/D6b). Capture
596
650
  # whether cache 001 was already applied at the moment this sync
597
651
  # acquired the lock. The end-of-loop marker write is gated on this so
@@ -742,7 +796,7 @@ def sync_cache(
742
796
  # path, which already cleared the flag and repopulates via the normal
743
797
  # walk). A path-less/:memory: conn has no cache_meta only if the schema
744
798
  # was never applied; the try/except tolerates that.
745
- if not rebuild:
799
+ if not rebuild and not targeted:
746
800
  try:
747
801
  _pending = conn.execute(
748
802
  "SELECT 1 FROM cache_meta "
@@ -865,7 +919,10 @@ def sync_cache(
865
919
  # HUMAN(text=args); the split-FTS UPDATE triggers re-index the args.
866
920
  _consume_promote_command_args(conn)
867
921
 
868
- paths: list[pathlib.Path] = list(_iter_claude_jsonl_files())
922
+ if targeted:
923
+ paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
924
+ else:
925
+ paths = list(_iter_claude_jsonl_files())
869
926
  stats.files_total = len(paths)
870
927
 
871
928
  # This SELECT does NOT open an implicit transaction (Python's
@@ -910,22 +967,29 @@ def sync_cache(
910
967
  # commit lands BEFORE the per-file read+parse loop, so no write
911
968
  # lock is held into that loop (same discipline as the truncation
912
969
  # escalation just below).
913
- on_disk_paths = {str(jp) for jp in paths}
914
- orphaned_tracked_paths = [
915
- p for p, (size_bytes, _, _) in existing.items()
916
- if size_bytes and p not in on_disk_paths
917
- ]
918
- if orphaned_tracked_paths:
919
- eprint(
920
- f"[cache] {len(orphaned_tracked_paths)} tracked file(s) no "
921
- f"longer on disk; invalidating walk-complete marker "
922
- f"(run `cache-sync --rebuild` to prune orphaned entries)"
923
- )
924
- conn.execute(
925
- "DELETE FROM cache_meta WHERE key='claude_ingest_walk_complete'"
926
- )
927
- conn.commit()
928
- walk_clean = False # orphaned rows -> cache doesn't mirror disk (D5a)
970
+ # Targeted (only_paths) sync narrows `paths` to the requested file(s),
971
+ # so the orphan scan below — which infers "deleted from disk" from a
972
+ # tracked path's absence in `paths` would mistake EVERY other tracked
973
+ # file for an orphan and nuke the walk-complete marker. Skip it entirely
974
+ # for targeted: the live-tail fast path never prunes orphans (the full
975
+ # background sync owns that).
976
+ if not targeted:
977
+ on_disk_paths = {str(jp) for jp in paths}
978
+ orphaned_tracked_paths = [
979
+ p for p, (size_bytes, _, _) in existing.items()
980
+ if size_bytes and p not in on_disk_paths
981
+ ]
982
+ if orphaned_tracked_paths:
983
+ eprint(
984
+ f"[cache] {len(orphaned_tracked_paths)} tracked file(s) no "
985
+ f"longer on disk; invalidating walk-complete marker "
986
+ f"(run `cache-sync --rebuild` to prune orphaned entries)"
987
+ )
988
+ conn.execute(
989
+ "DELETE FROM cache_meta WHERE key='claude_ingest_walk_complete'"
990
+ )
991
+ conn.commit()
992
+ walk_clean = False # orphaned rows -> cache doesn't mirror disk (D5a)
929
993
 
930
994
  # Pre-scan for any truncation among tracked files. Under the
931
995
  # ccusage-parity ON CONFLICT DO UPDATE, source_path is PINNED to
@@ -958,6 +1022,14 @@ def sync_cache(
958
1022
  truncated_paths.add(str(jp))
959
1023
 
960
1024
  if truncated_paths:
1025
+ if targeted:
1026
+ # The targeted fast path must NEVER trigger the global
1027
+ # full-cache wipe-and-re-ingest escalation below — that would
1028
+ # turn a 1s live-tail tick into a multi-minute rebuild and drop
1029
+ # every other session's rows. Decline and defer to the next full
1030
+ # background sync, which owns the truncation escalation.
1031
+ stats.deferred_reason = "truncation"
1032
+ return stats
961
1033
  eprint(
962
1034
  f"[cache-sync] truncation detected on {len(truncated_paths)} "
963
1035
  f"file(s) — re-ingesting all files (safe under ccusage-parity "
@@ -1035,6 +1107,7 @@ def sync_cache(
1035
1107
  except OSError as exc:
1036
1108
  eprint(f"[cache] stat failed for {jp}: {exc}")
1037
1109
  walk_clean = False # skipped a file without ingesting (D5a)
1110
+ stats.files_failed += 1
1038
1111
  continue
1039
1112
 
1040
1113
  size = st.st_size
@@ -1118,6 +1191,7 @@ def sync_cache(
1118
1191
  except OSError as exc:
1119
1192
  eprint(f"[cache] could not read {jp}: {exc}")
1120
1193
  walk_clean = False # skipped a file without ingesting (D5a)
1194
+ stats.files_failed += 1
1121
1195
  continue
1122
1196
 
1123
1197
  # Python's sqlite3 module starts an implicit transaction on the
@@ -1240,6 +1314,7 @@ def sync_cache(
1240
1314
  eprint(f"[cache] db error on {jp}: {exc}")
1241
1315
  conn.rollback()
1242
1316
  walk_clean = False # rolled back this file without ingesting (D5a)
1317
+ stats.files_failed += 1
1243
1318
  continue
1244
1319
 
1245
1320
  if progress is not None:
@@ -1280,7 +1355,7 @@ def sync_cache(
1280
1355
  # completeness. A lock-contended sync returned early above and never
1281
1356
  # reaches here. Presence (not the timestamp) is the gate signal; the
1282
1357
  # value stores the completion instant for doctor/debugging.
1283
- if walk_clean and applied_at_start:
1358
+ if walk_clean and applied_at_start and not targeted:
1284
1359
  conn.execute(
1285
1360
  "INSERT INTO cache_meta(key, value) "
1286
1361
  "VALUES('claude_ingest_walk_complete', ?) "
@@ -302,6 +302,8 @@ ALLOWED_CONFIG_KEYS = (
302
302
  "alerts.command_template",
303
303
  "dashboard.bind",
304
304
  "dashboard.expose_transcripts",
305
+ "dashboard.cache_failure_markers",
306
+ "dashboard.live_tail",
305
307
  "update.check.enabled",
306
308
  "update.check.ttl_hours",
307
309
  "statusline.visual_burn_rate",
@@ -478,6 +480,46 @@ def _config_known_value(config: dict, key: str) -> "object":
478
480
  except ValueError:
479
481
  return False
480
482
  return False
483
+ if key == "dashboard.cache_failure_markers":
484
+ # Boolean opt-OUT (spec §5). Default TRUE — absence is treated as ON
485
+ # (unlike dashboard.expose_transcripts, an opt-IN default of False).
486
+ # A hand-edited junk value surfaces the True default rather than
487
+ # crashing (mirrors dashboard.bind / expose_transcripts).
488
+ block = config.get("dashboard") if isinstance(config, dict) else None
489
+ if not isinstance(block, dict):
490
+ block = {}
491
+ stored = block.get("cache_failure_markers")
492
+ if stored is None:
493
+ return True
494
+ if isinstance(stored, bool):
495
+ return stored
496
+ # Only str spellings are normalizable; any other JSON scalar surfaces
497
+ # the default (the shared normalizer's .strip() would AttributeError on
498
+ # a bare int — uncaught by `except ValueError`).
499
+ if isinstance(stored, str):
500
+ try:
501
+ return c._normalize_alerts_enabled_value(stored)
502
+ except ValueError:
503
+ return True
504
+ return True
505
+ if key == "dashboard.live_tail":
506
+ # Boolean opt-OUT (spec §4.2). Default TRUE — absence is ON. A
507
+ # hand-edited junk value surfaces the True default. Mirrors
508
+ # dashboard.cache_failure_markers exactly.
509
+ block = config.get("dashboard") if isinstance(config, dict) else None
510
+ if not isinstance(block, dict):
511
+ block = {}
512
+ stored = block.get("live_tail")
513
+ if stored is None:
514
+ return True
515
+ if isinstance(stored, bool):
516
+ return stored
517
+ if isinstance(stored, str):
518
+ try:
519
+ return c._normalize_alerts_enabled_value(stored)
520
+ except ValueError:
521
+ return True
522
+ return True
481
523
  if key in ("update.check.enabled", "update.check.ttl_hours"):
482
524
  # Defaults mirror `_is_update_check_due` (True / 24 hours).
483
525
  # Hand-edited junk surfaces as the default — matches dashboard.bind.
@@ -864,6 +906,83 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
864
906
  f"{'true' if canonical else 'false'}"
865
907
  )
866
908
  return 0
909
+ if key == "dashboard.cache_failure_markers":
910
+ # Same read-modify-write posture as dashboard.expose_transcripts:
911
+ # validate first, then write under config_writer_lock with
912
+ # _load_config_unlocked (load_config inside the writer-lock
913
+ # self-deadlocks — fcntl.flock is per-fd). Preserves sibling
914
+ # dashboard.bind / dashboard.expose_transcripts. Reuse the shared
915
+ # bool-normalizer; catch + re-message with the actual key name (it
916
+ # hardcodes "alerts.enabled" in its ValueError text).
917
+ try:
918
+ canonical = c._normalize_alerts_enabled_value(raw)
919
+ except ValueError:
920
+ print(
921
+ f"cctally: invalid boolean value for "
922
+ f"dashboard.cache_failure_markers: "
923
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
924
+ file=sys.stderr,
925
+ )
926
+ return 2
927
+ with config_writer_lock():
928
+ config = _load_config_unlocked()
929
+ existing = config.get("dashboard")
930
+ if existing is not None and not isinstance(existing, dict):
931
+ print(
932
+ "cctally: dashboard config error: dashboard must be an object",
933
+ file=sys.stderr,
934
+ )
935
+ return 2
936
+ block = dict(existing or {})
937
+ block["cache_failure_markers"] = canonical
938
+ config["dashboard"] = block
939
+ save_config(config)
940
+ if getattr(args, "emit_json", False):
941
+ print(
942
+ json.dumps(
943
+ {"dashboard": {"cache_failure_markers": canonical}}, indent=2
944
+ )
945
+ )
946
+ else:
947
+ print(
948
+ f"dashboard.cache_failure_markers="
949
+ f"{'true' if canonical else 'false'}"
950
+ )
951
+ return 0
952
+ if key == "dashboard.live_tail":
953
+ # Mirror dashboard.cache_failure_markers exactly: validate the bool
954
+ # first, then read-modify-write under config_writer_lock with
955
+ # _load_config_unlocked (load_config under the lock self-deadlocks).
956
+ # Preserves sibling dashboard.bind / expose_transcripts /
957
+ # cache_failure_markers. Re-message the shared normalizer's ValueError
958
+ # with the actual key name.
959
+ try:
960
+ canonical = c._normalize_alerts_enabled_value(raw)
961
+ except ValueError:
962
+ print(
963
+ f"cctally: invalid boolean value for dashboard.live_tail: "
964
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
965
+ file=sys.stderr,
966
+ )
967
+ return 2
968
+ with config_writer_lock():
969
+ config = _load_config_unlocked()
970
+ existing = config.get("dashboard")
971
+ if existing is not None and not isinstance(existing, dict):
972
+ print(
973
+ "cctally: dashboard config error: dashboard must be an object",
974
+ file=sys.stderr,
975
+ )
976
+ return 2
977
+ block = dict(existing or {})
978
+ block["live_tail"] = canonical
979
+ config["dashboard"] = block
980
+ save_config(config)
981
+ if getattr(args, "emit_json", False):
982
+ print(json.dumps({"dashboard": {"live_tail": canonical}}, indent=2))
983
+ else:
984
+ print(f"dashboard.live_tail={'true' if canonical else 'false'}")
985
+ return 0
867
986
  if key in (
868
987
  "statusline.visual_burn_rate",
869
988
  "statusline.cost_source",
@@ -1236,6 +1355,37 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1236
1355
  save_config(config)
1237
1356
  # idempotent: silent on missing key
1238
1357
  return 0
1358
+ if key == "dashboard.cache_failure_markers":
1359
+ # Mirror the dashboard.expose_transcripts unset branch: drop only the
1360
+ # cache_failure_markers leaf; if the dashboard block ends up empty, drop
1361
+ # the parent too so config.json stays tidy. Sibling dashboard.bind /
1362
+ # expose_transcripts survive. Unsetting restores the True (opt-out)
1363
+ # default at read time.
1364
+ with config_writer_lock():
1365
+ config = _load_config_unlocked()
1366
+ block = config.get("dashboard")
1367
+ if isinstance(block, dict) and "cache_failure_markers" in block:
1368
+ del block["cache_failure_markers"]
1369
+ if not block:
1370
+ config.pop("dashboard", None)
1371
+ save_config(config)
1372
+ # idempotent: silent on missing key
1373
+ return 0
1374
+ if key == "dashboard.live_tail":
1375
+ # Mirror the cache_failure_markers unset branch: drop only the
1376
+ # live_tail leaf; if the dashboard block ends up empty, drop the parent
1377
+ # too. Sibling dashboard.bind / expose_transcripts / cache_failure_markers
1378
+ # survive. Unsetting restores the True (opt-out) default at read time.
1379
+ with config_writer_lock():
1380
+ config = _load_config_unlocked()
1381
+ block = config.get("dashboard")
1382
+ if isinstance(block, dict) and "live_tail" in block:
1383
+ del block["live_tail"]
1384
+ if not block:
1385
+ config.pop("dashboard", None)
1386
+ save_config(config)
1387
+ # idempotent: silent on missing key
1388
+ return 0
1239
1389
  if key in (
1240
1390
  "statusline.visual_burn_rate",
1241
1391
  "statusline.cost_source",