cctally 1.63.0 → 1.65.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,28 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.65.0] - 2026-07-09
9
+
10
+ ### Added
11
+ - Opt-in backend performance instrumentation: set `CCTALLY_PERF_TRACE=1` to print a phase-timing trace to stderr on `cache-sync` (and the hidden `tui --render-once` build), and read live backend timings + cache state from the dashboard's loopback-only `/api/debug/backend` endpoint. The trace is off by default and never changes command output — it goes to stderr only, so stdout stays byte-identical. New [docs/backend-performance.md](docs/backend-performance.md) documents the read model, hot paths, and invariants.
12
+ - Reproducible backend benchmark suite (`bin/cctally-bench` + `bin/build-bench-fixtures.py`, a dev/maintainer tool — never shipped): in-process timings for the dashboard snapshot (cold/warm/idle), cache ingest (no-op/delta), the conversation rail/search/find/payload/outline, and the warm reconcile paths, measured against a deterministic synthetic fixture, with a committed advisory baseline and an opt-in `--compare`/`--gate` (never a CI gate) that guard the recent backend performance wins from silent regression. See [bench/README.md](bench/README.md).
13
+ - The opt-in `CCTALLY_PERF_TRACE` backend trace now breaks whole-session conversation assembly into named sub-phases (read, dedup, build, correlate, fold, classify, cost, finalize) under the reader's detail/paginate phases, and the loopback `/api/debug/backend` diagnostic can now surface a conversation-open trace (list/search/outline/find/export/prompts/detail) as well as the data-snapshot build — so a slow transcript open is attributable to a specific stage on a live dashboard; off by default, no command output changes.
14
+ - `cctally-bench --assembly-scan` (dev/maintainer tool): sweeps the cost of assembling a whole conversation across a size ladder against its own isolated fixture and baseline (`bench/baselines/assembly.json`), the measurement behind the recorded decision to defer materializing pre-rendered conversation turns — assembly stays well under a 100 ms visibility budget for all but pathologically large (multi-thousand-turn) sessions, and the reader already caps payload bytes. See [docs/backend-performance.md](docs/backend-performance.md) §5.
15
+ - The conversation browse rail and cross-session search can now be filtered by **model family**: a new "Model" section in the filter popover lists the Opus / Sonnet / Haiku / Fable families that actually appear (each with a session count), and selecting one or more scopes both browsing and search to sessions that used that model — anywhere in the session, including its subagents — with removable chips and "Clear all" just like the other filters (#278).
16
+
17
+ ### Changed
18
+ - Conversation reader no longer re-processes an open conversation on every background refresh — it now recomputes only when the conversation actually grows, cutting idle CPU/battery use for anyone leaving a transcript open (#278).
19
+ - The dashboard now opens instantly on a heavy-history instance: it binds and paints the current-week and forecast panels almost immediately instead of waiting for the full ~2s data aggregation, and the heavier panels (sessions, projects, weekly, monthly, blocks, daily, trend, cache report) hydrate over the live stream showing a brief loading skeleton (respecting reduced-motion) rather than flashing a misleading "no data" / "restart the dashboard" empty state that then fills in (#278).
20
+ - A first-run or long-gap dashboard now fills in progressively as session history is ingested, instead of sitting blank and then snapping to fully-loaded in one jump; a warm returning session is unaffected, and a slow or backgrounded browser tab always converges to the latest data instead of getting stuck on a stale frame (#278).
21
+
22
+ ### Fixed
23
+ - The conversation filter popover now retries once if its filter options (the Project and Model lists) fail to load on a transient hiccup, instead of coming up empty and staying that way until it is reopened (#278).
24
+
25
+ ## [1.64.0] - 2026-07-07
26
+
27
+ ### Added
28
+ - cctally now sends an anonymous, opt-out install-count beat at most once a day so the project can gauge how many people actually use it. It is deliberately minimal and private: the only thing transmitted is a one-way token that rotates every month (derived from a random local id that never leaves your machine and cannot be recovered from the token), plus the cctally version and a coarse operating-system family (macos/linux/windows/other) — no IP, username, file paths, project names, or usage data — and nothing leaves your machine for at least 24 hours after first run, so you always have a window to opt out first. Run `cctally telemetry` to see the current state, why it is on or off, and exactly what would be sent; `cctally telemetry off` disables it (`cctally telemetry on` re-enables), `cctally telemetry reset` rotates your local id, and `cctally doctor` shows a read-only telemetry line. It is also disabled by `cctally config set telemetry.enabled false`, `CCTALLY_DISABLE_TELEMETRY=1`, the `DO_NOT_TRACK` convention, and automatically in development checkouts. Full transparency page — token construction, retention, and an honest threat model — is in [docs/telemetry.md](docs/telemetry.md).
29
+
8
30
  ## [1.63.0] - 2026-07-07
9
31
 
10
32
  ### Added
package/README.md CHANGED
@@ -154,6 +154,12 @@ If you also use OpenAI's Codex CLI, `cctally codex daily / monthly / session` ar
154
154
 
155
155
  `cctally doctor` gives you a read-only health check - install, hooks, sign-in, database, data freshness, pricing, and safety - and the same status shows up in the dashboard. `cctally pricing-check` warns you when the built-in model pricing is getting stale, `cctally db` manages the local database, and `cctally update` keeps your install current. The local cache is always safe to delete or rebuild.
156
156
 
157
+ ## Privacy & telemetry
158
+
159
+ cctally sends an anonymous, opt-out **install-count beat** — at most once a day — so the project can gauge how many people actually use it (npm and GitHub numbers are drowned in bots and mirrors). The entire payload is a one-way token that rotates every month (derived from a random local id that never leaves your machine and can't be recovered from the token), the cctally version, and a coarse OS family (`macos`/`linux`/`windows`/`other`). No identity, file paths, prompts, usage data, or stored IP — ever. Nothing leaves your machine for at least 24 hours after first run, so you always have a window to opt out first.
160
+
161
+ Turn it off any time with `cctally telemetry off` (or `cctally config set telemetry.enabled false`, `CCTALLY_DISABLE_TELEMETRY=1`, or the community-standard `DO_NOT_TRACK=1`); it's also off automatically in dev checkouts. Run `cctally telemetry` to see the current state and exactly what would be sent. The full transparency page — token construction, retention, and an honest threat model — is [docs/telemetry.md](docs/telemetry.md).
162
+
157
163
  ## A faster, local-first ccusage
158
164
 
159
165
  `cctally` started as a local replacement for [`ccusage`](https://github.com/ryoppippi/ccusage) and stays compatible at the level of common flows: `cctally claude <cmd>` is a drop-in for `ccusage claude <cmd>` (and `cctally codex <cmd>` for `ccusage codex <cmd>`), with the flat forms (`cctally daily`, `cctally codex-daily`, …) kept as aliases. Paste your ccusage commands verbatim - then reach for the dashboard, forecast, trend, conversation viewer, and alerts that ccusage doesn't have.
@@ -176,6 +182,7 @@ It's also fast. Pricing is built in and computed in-process from a local SQLite
176
182
  - [Configuration](docs/configuration.md): `config.json` shape and week-start rules.
177
183
  - [Architecture](docs/architecture.md): data flow, caches, week boundaries.
178
184
  - [Runtime data](docs/runtime-data.md): what lives in `~/.local/share/cctally/`.
185
+ - [Telemetry](docs/telemetry.md): the anonymous install-count beat, in full — and how to opt out.
179
186
  - [Command reference](docs/commands/): one page per subcommand.
180
187
 
181
188
  ## License
@@ -176,6 +176,12 @@ _should_replace = _lib_jsonl._should_replace
176
176
  _lib_conversation = _load_lib("_lib_conversation")
177
177
  _iter_message_rows = _lib_conversation.iter_message_rows
178
178
 
179
+ # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
180
+ # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
181
+ # shared no-op singleton), so the sync_cache seam wraps below cost nothing on
182
+ # the default path.
183
+ _perf = _load_lib("_lib_perf")
184
+
179
185
  # Shared by the fused per-file walk AND backfill_conversation_messages so the
180
186
  # column list, placeholders, and tuple order live in ONE place — a column
181
187
  # add/reorder can't silently desync the two ingest paths (which would land
@@ -857,7 +863,10 @@ def sync_cache(
857
863
 
858
864
  lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
859
865
  try:
860
- if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
866
+ with _perf.phase("flock") as _p_flock:
867
+ _acquired = _acquire_cache_flock(lock_fh, timeout=lock_timeout)
868
+ _p_flock.set_meta(contended=not _acquired)
869
+ if not _acquired:
861
870
  eprint("[cache] sync already in progress; using existing cache")
862
871
  stats.lock_contended = True
863
872
  return stats
@@ -1044,6 +1053,12 @@ def sync_cache(
1044
1053
  # path, which already cleared the flag and repopulates via the normal
1045
1054
  # walk). A path-less/:memory: conn has no cache_meta only if the schema
1046
1055
  # was never applied; the try/except tolerates that.
1056
+ # #276 perf: bracket the (rare, upgrade-only) backfill/reingest region
1057
+ # as one coarse "backfills" phase. Opened via the context-manager
1058
+ # protocol rather than a ``with`` block so the ~150-line body below is
1059
+ # not reindented. Near-noop when tracing is off (_NULL_PHASE).
1060
+ _p_backfills = _perf.phase("backfills")
1061
+ _p_backfills.__enter__()
1047
1062
  if not rebuild and not targeted:
1048
1063
  try:
1049
1064
  _pending = conn.execute(
@@ -1191,12 +1206,15 @@ def sync_cache(
1191
1206
  # IGNORE). Touches ONLY conversation_file_touches, never
1192
1207
  # conversation_messages (P1-2).
1193
1208
  _consume_file_touches(conn)
1209
+ _p_backfills.__exit__(None, None, None)
1194
1210
 
1195
- if targeted:
1196
- paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
1197
- else:
1198
- paths = list(_iter_claude_jsonl_files())
1199
- stats.files_total = len(paths)
1211
+ with _perf.phase("discover") as _p_disc:
1212
+ if targeted:
1213
+ paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
1214
+ else:
1215
+ paths = list(_iter_claude_jsonl_files())
1216
+ stats.files_total = len(paths)
1217
+ _p_disc.set_count(len(paths))
1200
1218
 
1201
1219
  # This SELECT does NOT open an implicit transaction (Python's
1202
1220
  # sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
@@ -1406,6 +1424,12 @@ def sync_cache(
1406
1424
  # zero-write-lock read/parse region, so it adds no DML there.
1407
1425
  touched_sessions: set = set()
1408
1426
 
1427
+ # #276 perf: bracket the fused per-file ingest loop as ONE coarse
1428
+ # "walk" phase (never per-row — Section 2 rule: volume is a count, not
1429
+ # N timed phases). Opened via the context-manager protocol so the hot
1430
+ # loop body below is not reindented; counts recorded after the loop.
1431
+ _p_walk = _perf.phase("walk")
1432
+ _p_walk.__enter__()
1409
1433
  for jp in paths:
1410
1434
  path_str = str(jp)
1411
1435
  # Backfill session_id/project_path for A2 `session` subcommand.
@@ -1683,6 +1707,13 @@ def sync_cache(
1683
1707
 
1684
1708
  if progress is not None:
1685
1709
  progress(stats)
1710
+ _p_walk.__exit__(None, None, None)
1711
+ _p_walk.set_count(stats.files_processed)
1712
+ _p_walk.set_meta(
1713
+ skipped=stats.files_skipped_unchanged,
1714
+ failed=stats.files_failed,
1715
+ rows=stats.rows_changed,
1716
+ )
1686
1717
 
1687
1718
  # Browse-rail rollup maintenance (single post-walk recompute, under the
1688
1719
  # still-held flock, after every per-file commit and before the
@@ -1698,16 +1729,17 @@ def sync_cache(
1698
1729
  # ~1 session/tick). Both recomputes derive COUNT/MIN/MAX from the same
1699
1730
  # rows the rail's old live aggregate read, so the rollup stays
1700
1731
  # byte-identical to that aggregate.
1701
- if _conversation_sessions_backfill_pending(conn):
1702
- _recompute_conversation_sessions(conn)
1703
- conn.execute(
1704
- "DELETE FROM cache_meta "
1705
- "WHERE key='conversation_sessions_backfill_pending'"
1706
- )
1707
- conn.commit()
1708
- elif touched_sessions:
1709
- _recompute_conversation_sessions(conn, touched_sessions)
1710
- conn.commit()
1732
+ with _perf.phase("recompute.conversation_sessions"):
1733
+ if _conversation_sessions_backfill_pending(conn):
1734
+ _recompute_conversation_sessions(conn)
1735
+ conn.execute(
1736
+ "DELETE FROM cache_meta "
1737
+ "WHERE key='conversation_sessions_backfill_pending'"
1738
+ )
1739
+ conn.commit()
1740
+ elif touched_sessions:
1741
+ _recompute_conversation_sessions(conn, touched_sessions)
1742
+ conn.commit()
1711
1743
 
1712
1744
  # Walk-complete sentinel write (cctally-dev#93, D5a). Still inside the
1713
1745
  # held fcntl lock, before the finally-unlock. Only when the entire walk
@@ -3370,6 +3402,10 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3370
3402
  default is 'all'.
3371
3403
  """
3372
3404
  source = getattr(args, "source", "all")
3405
+ # #276 perf: clear any prior tree on this thread so a leaked root can't be
3406
+ # flushed, then (below) time the Claude sync_cache call as the "sync_cache"
3407
+ # root phase and flush the tree to stderr when CCTALLY_PERF_TRACE is set.
3408
+ _perf.reset_thread()
3373
3409
  conn = open_cache_db()
3374
3410
 
3375
3411
  # --prune-orphans: fast, targeted cleanup of cache rows whose source
@@ -3419,9 +3455,10 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3419
3455
  contended = False
3420
3456
 
3421
3457
  if source in ("claude", "all"):
3422
- stats = sync_cache(
3423
- conn, progress=_progress_stderr, rebuild=args.rebuild, lock_timeout=lt
3424
- )
3458
+ with _perf.phase("sync_cache"):
3459
+ stats = sync_cache(
3460
+ conn, progress=_progress_stderr, rebuild=args.rebuild, lock_timeout=lt
3461
+ )
3425
3462
  _progress_stderr(stats, force=True)
3426
3463
  if stats.lock_contended and args.rebuild:
3427
3464
  eprint(
@@ -3456,4 +3493,8 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3456
3493
  f"{stats.rows_changed} rows changed"
3457
3494
  )
3458
3495
 
3496
+ # #276 perf: when tracing is enabled, flush the completed "sync_cache"
3497
+ # phase tree to stderr (stdout stays byte-identical). No-op when off.
3498
+ if _perf.enabled():
3499
+ _perf.flush_stderr(_perf.current_root())
3459
3500
  return 1 if contended else 0
@@ -318,6 +318,7 @@ ALLOWED_CONFIG_KEYS = (
318
318
  "budget.projects",
319
319
  "budget.project_alerts_enabled",
320
320
  "budget.codex",
321
+ "telemetry.enabled",
321
322
  )
322
323
 
323
324
 
@@ -540,6 +541,25 @@ def _config_known_value(config: dict, key: str) -> "object":
540
541
  except ValueError:
541
542
  return True
542
543
  return True
544
+ if key == "telemetry.enabled":
545
+ # Boolean opt-OUT (anonymous install-count telemetry, spec 2026-07-07).
546
+ # Default TRUE — absence is ON. A hand-edited junk value surfaces the
547
+ # True default. Mirrors dashboard.live_tail exactly, under a top-level
548
+ # `telemetry` block instead of `dashboard`.
549
+ block = config.get("telemetry") if isinstance(config, dict) else None
550
+ if not isinstance(block, dict):
551
+ block = {}
552
+ stored = block.get("enabled")
553
+ if stored is None:
554
+ return True
555
+ if isinstance(stored, bool):
556
+ return stored
557
+ if isinstance(stored, str):
558
+ try:
559
+ return c._normalize_alerts_enabled_value(stored)
560
+ except ValueError:
561
+ return True
562
+ return True
543
563
  if key in ("update.check.enabled", "update.check.ttl_hours"):
544
564
  # Defaults mirror `_is_update_check_due` (True / 24 hours).
545
565
  # Hand-edited junk surfaces as the default — matches dashboard.bind.
@@ -1006,6 +1026,40 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1006
1026
  else:
1007
1027
  print(f"dashboard.live_tail={'true' if canonical else 'false'}")
1008
1028
  return 0
1029
+ if key == "telemetry.enabled":
1030
+ # Anonymous install-count telemetry opt-out (spec 2026-07-07). Mirror
1031
+ # dashboard.live_tail exactly: validate the bool first, then
1032
+ # read-modify-write under config_writer_lock with _load_config_unlocked
1033
+ # (load_config under the lock self-deadlocks). Preserves any sibling
1034
+ # telemetry.* keys. Re-message the shared normalizer's ValueError with
1035
+ # the actual key name.
1036
+ try:
1037
+ canonical = c._normalize_alerts_enabled_value(raw)
1038
+ except ValueError:
1039
+ print(
1040
+ f"cctally: invalid boolean value for telemetry.enabled: "
1041
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
1042
+ file=sys.stderr,
1043
+ )
1044
+ return 2
1045
+ with config_writer_lock():
1046
+ config = _load_config_unlocked()
1047
+ existing = config.get("telemetry")
1048
+ if existing is not None and not isinstance(existing, dict):
1049
+ print(
1050
+ "cctally: telemetry config error: telemetry must be an object",
1051
+ file=sys.stderr,
1052
+ )
1053
+ return 2
1054
+ block = dict(existing or {})
1055
+ block["enabled"] = canonical
1056
+ config["telemetry"] = block
1057
+ save_config(config)
1058
+ if getattr(args, "emit_json", False):
1059
+ print(json.dumps({"telemetry": {"enabled": canonical}}, indent=2))
1060
+ else:
1061
+ print(f"telemetry.enabled={'true' if canonical else 'false'}")
1062
+ return 0
1009
1063
  if key in (
1010
1064
  "statusline.visual_burn_rate",
1011
1065
  "statusline.cost_source",
@@ -1411,6 +1465,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1411
1465
  save_config(config)
1412
1466
  # idempotent: silent on missing key
1413
1467
  return 0
1468
+ if key == "telemetry.enabled":
1469
+ # Mirror the dashboard.live_tail unset branch: drop only the enabled
1470
+ # leaf; if the telemetry block ends up empty, drop the parent too.
1471
+ # Unsetting restores the True (opt-out) default at read time.
1472
+ with config_writer_lock():
1473
+ config = _load_config_unlocked()
1474
+ block = config.get("telemetry")
1475
+ if isinstance(block, dict) and "enabled" in block:
1476
+ del block["enabled"]
1477
+ if not block:
1478
+ config.pop("telemetry", None)
1479
+ save_config(config)
1480
+ # idempotent: silent on missing key
1481
+ return 0
1414
1482
  if key in (
1415
1483
  "statusline.visual_burn_rate",
1416
1484
  "statusline.cost_source",
@@ -69,6 +69,8 @@ def _init_paths_from_env() -> None:
69
69
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
70
70
  global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
71
71
  global CLAUDE_PROJECTS_DIR
72
+ global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
73
+ global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
72
74
 
73
75
  home = pathlib.Path.home()
74
76
 
@@ -124,6 +126,14 @@ def _init_paths_from_env() -> None:
124
126
  UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
125
127
  UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
126
128
 
129
+ # Anonymous install-count telemetry markers (see spec 2026-07-07).
130
+ # All four derive from APP_DIR and are re-bound here so a redirected
131
+ # APP_DIR (tests, dev-instance isolation) carries them along.
132
+ TELEMETRY_INSTALL_ID_PATH = APP_DIR / "install_id"
133
+ TELEMETRY_LAST_BEAT_PATH = APP_DIR / "telemetry.last-beat"
134
+ TELEMETRY_NOTICE_SHOWN_PATH = APP_DIR / "telemetry.notice-shown"
135
+ TELEMETRY_FIRST_SEEN_PATH = APP_DIR / "telemetry.first-seen"
136
+
127
137
  CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
128
138
 
129
139
  # Claude session JSONL root. Production path is `~/.claude/projects`;
@@ -189,6 +199,25 @@ def _real_prod_data_dir() -> pathlib.Path:
189
199
  _init_paths_from_env()
190
200
 
191
201
 
202
+ # === Telemetry constants (non-path; see spec 2026-07-07) =============
203
+ #
204
+ # These are static (not APP_DIR-derived) so they live outside
205
+ # `_init_paths_from_env()`. The kernel `bin/_cctally_telemetry.py` reads
206
+ # them at call time via its `_core()` accessor.
207
+ #
208
+ # Public, non-secret domain-separation constant folded into the monthly
209
+ # rotating token (SHA-256, truncated to 32 hex). It only namespaces
210
+ # cctally's token from any other consumer of the same install_id — it is
211
+ # NOT a secret and leaking it discloses nothing about the install.
212
+ TELEMETRY_PEPPER = "cctally-install-count-v1"
213
+ # Default beat endpoint; overridable for tests via CCTALLY_TELEMETRY_ENDPOINT.
214
+ TELEMETRY_ENDPOINT_DEFAULT = "https://cctally-telemetry.cctally.workers.dev/beat"
215
+ # Send at most one beat per this many seconds (mtime-gated on the beat marker).
216
+ TELEMETRY_BEAT_THROTTLE_SECONDS = 24 * 3600
217
+ # Wait this long after first eligibility before sending the first beat.
218
+ TELEMETRY_FIRST_BEAT_GRACE_SECONDS = 24 * 3600
219
+
220
+
192
221
  def _resolve_claude_projects_dirs() -> list[pathlib.Path]:
193
222
  """Return Claude Code projects dirs that exist on disk, env-aware.
194
223