cctally 1.66.0 → 1.67.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.
@@ -28,7 +28,7 @@ import sys
28
28
 
29
29
  from _cctally_core import (
30
30
  _command_as_of,
31
- _reset_aware_floor,
31
+ _floored_week_max,
32
32
  eprint,
33
33
  open_db,
34
34
  parse_iso_datetime,
@@ -82,45 +82,18 @@ def _load_week_snapshots(
82
82
  "AND datetime(week_end_at) > datetime(?)",
83
83
  (until.isoformat(), since.isoformat()),
84
84
  )
85
- rows = cur.fetchall()
86
- # Resolve each week's clamp floor once (keyed on week_start_date) so a
87
- # mid-week credit doesn't re-inflate the per-project Used %. None means
88
- # "no floor" — every captured row counts.
89
- floor_epoch: dict[str, int | None] = {}
90
-
91
- def _floor_for(wsd, ws_iso, we_iso):
92
- if wsd not in floor_epoch:
93
- f_iso = _reset_aware_floor(conn, wsd, ws_iso, we_iso)
94
- floor_epoch[wsd] = (
95
- int(parse_iso_datetime(f_iso, "project.floor").timestamp())
96
- if f_iso else None
97
- )
98
- return floor_epoch[wsd]
99
-
100
- result: dict[dt.datetime, float] = {}
101
- for wsd, ws_iso, we_iso, cap_iso, pct in rows:
85
+ rows_in = []
86
+ for wsd, ws_iso, we_iso, cap_iso, pct in cur.fetchall():
102
87
  if ws_iso is None or pct is None:
103
88
  continue
104
- floor = _floor_for(wsd, ws_iso, we_iso)
105
- if floor is not None and cap_iso is not None:
106
- try:
107
- cap_epoch = int(
108
- parse_iso_datetime(str(cap_iso), "project.cap").timestamp()
109
- )
110
- except ValueError:
111
- cap_epoch = None
112
- # Drop pre-floor (stale pre-credit) snapshots from the MAX.
113
- if cap_epoch is not None and cap_epoch < floor:
114
- continue
115
- ws = dt.datetime.fromisoformat(
116
- str(ws_iso).replace("Z", "+00:00")
117
- )
89
+ ws = dt.datetime.fromisoformat(str(ws_iso).replace("Z", "+00:00"))
118
90
  key = ws.astimezone(dt.timezone.utc)
119
- pct_f = float(pct)
120
- prev = result.get(key)
121
- if prev is None or pct_f > prev:
122
- result[key] = pct_f
123
- return result
91
+ rows_in.append((key, wsd, ws_iso, we_iso, cap_iso, pct))
92
+ # Reset-aware per-week max (#290): the shared reducer resolves each
93
+ # week's clamp floor once (keyed on week_start_date) and drops stale
94
+ # pre-credit captures before taking the per-week maximum. The query
95
+ # above already filters NULL bounds, so canonical bounds are present.
96
+ return _floored_week_max(conn, rows_in)
124
97
  finally:
125
98
  conn.close()
126
99
 
@@ -64,6 +64,7 @@ import pathlib
64
64
  import shutil
65
65
  import subprocess
66
66
  import sys
67
+ import threading
67
68
  import time
68
69
 
69
70
 
@@ -1769,6 +1770,84 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1769
1770
  return 0
1770
1771
 
1771
1772
 
1773
+ _SETUP_PROGRESS_MIN_INTERVAL_SECONDS = 1.0
1774
+ _SETUP_PROGRESS_HEARTBEAT_POLL_SECONDS = 0.5
1775
+
1776
+
1777
+ def _setup_progress_enabled(*, json_mode: bool) -> bool:
1778
+ """Whether to emit setup cold-sync progress to stderr (issue #281 S7 / R6).
1779
+
1780
+ ``CCTALLY_SETUP_PROGRESS='1'`` forces on, ``'0'`` forces off (both
1781
+ directions: the golden harness force-suppresses, the timed
1782
+ fixture-ladder run force-observes). Otherwise: on iff stderr is a TTY
1783
+ and not ``--json`` (json is machine-readable — auto-suppress the
1784
+ friendly notices there). Gating on ``sys.stderr`` (not stdout) is
1785
+ correct because progress goes to stderr, so a user who pipes stdout
1786
+ still sees it while the harness (command-substitution pipe) reads
1787
+ non-TTY.
1788
+ """
1789
+ env = os.environ.get("CCTALLY_SETUP_PROGRESS")
1790
+ if env == "1":
1791
+ return True
1792
+ if env == "0":
1793
+ return False
1794
+ return (not json_mode) and sys.stderr.isatty()
1795
+
1796
+
1797
+ class _SetupProgressReporter:
1798
+ """Best-effort stderr progress for setup's cold-sync bootstrap.
1799
+
1800
+ Coordinates the per-file ``sync_cache(progress=)`` callback with a
1801
+ background heartbeat so neither a single giant JSONL nor a slow OAuth
1802
+ fetch leaves a silent gap > ~2s. All emissions throttle off one shared
1803
+ monotonic ``last_emit`` under a lock; ``force=True`` bypasses the
1804
+ throttle (used for the pre-sync and pre-OAuth notices). A no-op (and no
1805
+ thread) when disabled — so it is inert in the golden harness / non-TTY
1806
+ / ``--json``.
1807
+ """
1808
+
1809
+ def __init__(self, enabled: bool) -> None:
1810
+ self.enabled = enabled
1811
+ self._last_emit = 0.0
1812
+ self._lock = threading.Lock()
1813
+ self._stop = threading.Event()
1814
+ self._thread: "threading.Thread | None" = None
1815
+
1816
+ def emit(self, msg: str, *, force: bool = False) -> None:
1817
+ if not self.enabled:
1818
+ return
1819
+ with self._lock:
1820
+ now = time.monotonic()
1821
+ if not force and (now - self._last_emit) < _SETUP_PROGRESS_MIN_INTERVAL_SECONDS:
1822
+ return
1823
+ self._last_emit = now
1824
+ eprint(msg)
1825
+
1826
+ def sync_callback(self, stats) -> None:
1827
+ # `stats` is a duck-typed cache.IngestStats (reads files_processed /
1828
+ # files_total). sync_cache invokes this per file; emit() throttles.
1829
+ self.emit(f" … synced {stats.files_processed}/{stats.files_total} session files")
1830
+
1831
+ def _heartbeat_loop(self) -> None:
1832
+ # Fires only when no per-file line / notice emitted in the last
1833
+ # interval, bounding intra-file and intra-fetch silence.
1834
+ while not self._stop.wait(_SETUP_PROGRESS_HEARTBEAT_POLL_SECONDS):
1835
+ self.emit(" … still working…")
1836
+
1837
+ def __enter__(self) -> "_SetupProgressReporter":
1838
+ if self.enabled:
1839
+ self._thread = threading.Thread(
1840
+ target=self._heartbeat_loop, name="cctally-setup-progress", daemon=True
1841
+ )
1842
+ self._thread.start()
1843
+ return self
1844
+
1845
+ def __exit__(self, *exc) -> None:
1846
+ self._stop.set()
1847
+ if self._thread is not None:
1848
+ self._thread.join(timeout=1.0)
1849
+
1850
+
1772
1851
  def _setup_emit_text(lines: list[str]) -> None:
1773
1852
  for ln in lines:
1774
1853
  print(ln)
@@ -2075,49 +2154,72 @@ def _setup_install(args: argparse.Namespace) -> int:
2075
2154
  out.append(" leave (data is funneled correctly either way), but you can remove")
2076
2155
  out.append(" it whenever you want. We won't touch the file.")
2077
2156
 
2078
- # Bootstrap (non-fatal). sync_cache requires a connection arg mirror
2079
- # the pattern from cmd_hook_tick (Task 2 fix).
2157
+ # Bootstrap (non-fatal). Progress is stderr-only + TTY-gated so the
2158
+ # buffered stdout transcript and --json envelope are unchanged (issue
2159
+ # #281 S7 / R6). The reporter wraps BOTH the cold-sync ingest and the
2160
+ # OAuth fetch under one daemon heartbeat, so neither a single giant
2161
+ # JSONL nor a slow OAuth network call leaves a silent gap > ~2s.
2162
+ # sync_cache is untouched — we only pass its existing progress= seam.
2080
2163
  bootstrap_rows: int | None = None
2081
2164
  bootstrap_oauth_status: str | None = None
2082
- try:
2083
- cache_conn = c.open_cache_db()
2165
+ progress = _SetupProgressReporter(
2166
+ _setup_progress_enabled(json_mode=getattr(args, "json", False))
2167
+ )
2168
+ with progress:
2169
+ progress.emit(
2170
+ "⏳ Syncing session history (first run can take a moment on a large history)…",
2171
+ force=True,
2172
+ )
2084
2173
  try:
2085
- stats = c.sync_cache(cache_conn)
2086
- rows = int(stats.rows_changed)
2087
- finally:
2174
+ cache_conn = c.open_cache_db()
2088
2175
  try:
2089
- cache_conn.close()
2090
- except Exception:
2091
- pass
2092
- bootstrap_rows = rows
2093
- # `rows` counts both genuine INSERTs and ccusage-parity DO UPDATE
2094
- # replacements (see IngestStats.rows_changed). On first install
2095
- # this is always 0-vs-N pure inserts (cache is empty), so "N new
2096
- # entries" is exactly accurate. On a re-install / upgrade path
2097
- # with active sessions, `rows` also counts UPSERT replacements
2098
- # (streaming-vs-final tiebreaker swaps), so the count is more
2099
- # accurately "ingest activity" than "rows newly added" — but
2100
- # we keep "new entries" because (a) it's still a useful signal
2101
- # to the operator that the cache is alive, and (b) the dominant
2102
- # case (first install) reads literally.
2103
- out.append(f"✓ Synced session cache ({rows} new entries)")
2104
- except Exception as exc:
2105
- out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
2106
- warnings += 1
2107
- if oauth:
2108
- try:
2109
- status, _ = c._hook_tick_oauth_refresh()
2110
- bootstrap_oauth_status = status
2111
- if status.startswith("ok"):
2112
- c._hook_tick_throttle_touch()
2113
- out.append(f"✓ Bootstrapped weekly usage ({status})")
2114
- else:
2115
- out.append(f"⚠ Bootstrap OAuth fetch: {status}")
2176
+ stats = c.sync_cache(cache_conn, progress=progress.sync_callback)
2177
+ finally:
2178
+ try:
2179
+ cache_conn.close()
2180
+ except Exception:
2181
+ pass
2182
+ if stats.lock_contended:
2183
+ # Another process holds the cache flock nothing was
2184
+ # ingested (sync_cache short-circuits with rows_changed=0).
2185
+ # Report honestly rather than a false "Synced (0 new
2186
+ # entries)"; leave bootstrap_rows None for the envelope.
2187
+ out.append("⚠ Another cache sync is in progress; using existing cache.")
2116
2188
  warnings += 1
2189
+ bootstrap_rows = None
2190
+ else:
2191
+ rows = int(stats.rows_changed)
2192
+ bootstrap_rows = rows
2193
+ # `rows` counts both genuine INSERTs and ccusage-parity DO
2194
+ # UPDATE replacements (see IngestStats.rows_changed). On
2195
+ # first install this is always 0-vs-N pure inserts (cache is
2196
+ # empty), so "N new entries" is exactly accurate. On a
2197
+ # re-install / upgrade path with active sessions, `rows` also
2198
+ # counts UPSERT replacements (streaming-vs-final tiebreaker
2199
+ # swaps), so the count is more accurately "ingest activity"
2200
+ # than "rows newly added" — but we keep "new entries" because
2201
+ # (a) it's still a useful signal to the operator that the
2202
+ # cache is alive, and (b) the dominant case (first install)
2203
+ # reads literally.
2204
+ out.append(f"✓ Synced session cache ({rows} new entries)")
2117
2205
  except Exception as exc:
2118
- bootstrap_oauth_status = f"err({type(exc).__name__})"
2119
- out.append(f"⚠ Bootstrap OAuth failed: {exc}")
2206
+ out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
2120
2207
  warnings += 1
2208
+ if oauth:
2209
+ progress.emit("⏳ Fetching current usage…", force=True)
2210
+ try:
2211
+ status, _ = c._hook_tick_oauth_refresh()
2212
+ bootstrap_oauth_status = status
2213
+ if status.startswith("ok"):
2214
+ c._hook_tick_throttle_touch()
2215
+ out.append(f"✓ Bootstrapped weekly usage ({status})")
2216
+ else:
2217
+ out.append(f"⚠ Bootstrap OAuth fetch: {status}")
2218
+ warnings += 1
2219
+ except Exception as exc:
2220
+ bootstrap_oauth_status = f"err({type(exc).__name__})"
2221
+ out.append(f"⚠ Bootstrap OAuth failed: {exc}")
2222
+ warnings += 1
2121
2223
 
2122
2224
  out.append("")
2123
2225
  if warnings:
@@ -2125,13 +2227,6 @@ def _setup_install(args: argparse.Namespace) -> int:
2125
2227
  else:
2126
2228
  out.append("cctally is ready.")
2127
2229
  out.append("")
2128
- # Settings.json was modified — CC caches it at session start. The
2129
- # warning fires unconditionally because `_setup_install` always
2130
- # rewrites settings.json (legacy migration, fresh install, repair).
2131
- out.append("⚠ Restart Claude Code for the new hooks to take effect in any currently")
2132
- out.append(" open sessions. New sessions launched after this point pick them up")
2133
- out.append(" automatically. (settings.json is cached at session start.)")
2134
- out.append("")
2135
2230
  out.append(" Try:")
2136
2231
  out.append(" cctally daily # last 30 days")
2137
2232
  out.append(" cctally dashboard # live web dashboard")
@@ -2151,6 +2246,18 @@ def _setup_install(args: argparse.Namespace) -> int:
2151
2246
  out.append("Opt out anytime: cctally telemetry off (or CCTALLY_DISABLE_TELEMETRY=1)")
2152
2247
  out.append("How it works: https://github.com/omrikais/cctally/blob/main/docs/telemetry.md")
2153
2248
 
2249
+ # ▶ Next step LAST: the sole required action, set off from the wall of
2250
+ # text (issue #281 S7 / R6 — it was previously buried mid-dump, right
2251
+ # after the readiness line and followed by ~12 more lines). It's an
2252
+ # action, not a warning, so it drops the `⚠` and never touched the
2253
+ # `warnings` count. settings.json is cached at session start, so open
2254
+ # sessions need a restart; `_setup_install` always rewrites settings.json
2255
+ # (legacy migration, fresh install, repair), so this always applies.
2256
+ out.append("")
2257
+ out.append("▶ Next step: restart Claude Code to activate the new hooks.")
2258
+ out.append(" Open sessions won't record until you restart (settings.json is cached at")
2259
+ out.append(" session start). New sessions started after this pick them up automatically.")
2260
+
2154
2261
  if getattr(args, "json", False):
2155
2262
  # JSON-safe telemetry disclosure (spec 2026-07-07 §4): a structured
2156
2263
  # field, never prose. Resolved READ-ONLY via `resolve_telemetry_state`
@@ -0,0 +1,191 @@
1
+ """cctally transcript — anonymized-by-default export + raw cross-session search
2
+ over the conversation cache (#281 S4 R4).
3
+
4
+ Thin CLI wrappers over the SAME kernels the dashboard HTTP routes use
5
+ (``get_conversation_export`` / ``search_conversations`` / ``build_anon_plan_for_db``
6
+ / ``scrub_text``), so ``transcript export`` byte-matches ``GET …/export`` and the
7
+ viewer becomes scriptable.
8
+
9
+ - ``export``: anonymized Markdown by default; ``--raw`` disables the whole scrub
10
+ (identity + secrets) and is byte-identical to the dashboard's raw export.
11
+ Byte-exact emission: ``sys.stdout.buffer.write`` of the exact UTF-8 bytes (no
12
+ ``print``, no added trailing newline — the render already ends in exactly one),
13
+ or ``--output PATH`` writes the same exact bytes; nothing else on stdout.
14
+ Unknown session → ``transcript: …`` on stderr, exit 1.
15
+ - ``search``: RAW output (a navigation surface, not a sharing artifact). Mirrors
16
+ the full HTTP filter surface; date-only values parse through the SAME
17
+ display-tz-aware helper the HTTP handler uses. Human table by default; ``--json``
18
+ emits a ``schemaVersion``-stamped, explicitly camelCased envelope.
19
+
20
+ Accessor discipline: no ``_cctally_*`` sibling is imported directly; every helper
21
+ is reached via the call-time ``_cctally()`` accessor / ``_load_sibling`` (matching
22
+ the other command modules), except the pure ``_lib_fmt._boxed_table`` renderer.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import pathlib
28
+ import sys
29
+
30
+ from _cctally_core import eprint
31
+ from _lib_fmt import _boxed_table
32
+
33
+
34
+ def _cctally():
35
+ """Call-time accessor to the cctally module namespace (ns-patchable)."""
36
+ return sys.modules["cctally"]
37
+
38
+
39
+ def cmd_transcript(args) -> int:
40
+ action = getattr(args, "transcript_action", None)
41
+ if action == "export":
42
+ return _cmd_transcript_export(args)
43
+ if action == "search":
44
+ return _cmd_transcript_search(args)
45
+ eprint("transcript: expected a subcommand (`export` or `search`)")
46
+ return 2
47
+
48
+
49
+ # ---- export ----------------------------------------------------------------
50
+
51
+ def _cmd_transcript_export(args) -> int:
52
+ c = _cctally()
53
+ session_id = args.session_id
54
+ scope = getattr(args, "scope", "all")
55
+ raw = bool(getattr(args, "raw", False))
56
+ output = getattr(args, "output", None)
57
+
58
+ conn = c.open_cache_db()
59
+ try:
60
+ cq = c._load_sibling("_lib_conversation_query")
61
+ md = cq.get_conversation_export(conn, session_id, scope)
62
+ if md is None:
63
+ eprint(f"transcript: no conversation found for session {session_id!r}")
64
+ return 1
65
+ if not raw:
66
+ anon = c._load_sibling("_lib_conversation_anon")
67
+ plan = cq.build_anon_plan_for_db(conn, home_dir=os.path.expanduser("~"))
68
+ md = anon.scrub_text(md, plan)
69
+ finally:
70
+ conn.close()
71
+
72
+ data = md.encode("utf-8")
73
+ if output:
74
+ pathlib.Path(output).write_bytes(data)
75
+ else:
76
+ # Byte-exact: raw bytes to stdout, no `print`, no added trailing newline.
77
+ sys.stdout.buffer.write(data)
78
+ sys.stdout.buffer.flush()
79
+ return 0
80
+
81
+
82
+ # ---- search ----------------------------------------------------------------
83
+
84
+ def _cmd_transcript_search(args) -> int:
85
+ c = _cctally()
86
+ query = args.query
87
+ kind = getattr(args, "kind", "all")
88
+ limit = getattr(args, "limit", 50)
89
+ offset = getattr(args, "offset", 0)
90
+ projects = getattr(args, "project", None) or None
91
+ models = getattr(args, "model", None) or None
92
+ cost_min = getattr(args, "cost_min", None)
93
+ cost_max = getattr(args, "cost_max", None)
94
+ rebuild_min = getattr(args, "rebuild_min", None)
95
+
96
+ # Date-only bounds parse through the SAME display-tz-aware helper the HTTP
97
+ # filter handler uses (no second parser) — reuse, don't reimplement.
98
+ date_from_in = getattr(args, "date_from", None)
99
+ date_to_in = getattr(args, "date_to", None)
100
+ df = dtt = None
101
+ if date_from_in or date_to_in:
102
+ tz = c.resolve_display_tz(args, c.load_config())
103
+ tz_name = tz.key if tz is not None else None
104
+ dates = c._load_sibling("_lib_dashboard_dates")
105
+ try:
106
+ df, dtt = dates.parse_filter_date_range(
107
+ date_from_in, date_to_in, tz_name=tz_name)
108
+ except ValueError as exc:
109
+ eprint(f"transcript: {exc}")
110
+ return 2
111
+
112
+ conn = c.open_cache_db()
113
+ try:
114
+ cq = c._load_sibling("_lib_conversation_query")
115
+ try:
116
+ result = cq.search_conversations(
117
+ conn, query, limit=limit, offset=offset, kind=kind,
118
+ date_from=df, date_to=dtt, projects=projects, models=models,
119
+ cost_min=cost_min, cost_max=cost_max, rebuild_min=rebuild_min)
120
+ except ValueError as exc: # unknown kind (belt-and-suspenders)
121
+ eprint(f"transcript: {exc}")
122
+ return 2
123
+ finally:
124
+ conn.close()
125
+
126
+ if getattr(args, "json", False):
127
+ payload = c.stamp_schema_version(_search_to_camel(result))
128
+ print(_json_dumps(payload))
129
+ return 0
130
+ _render_search_table(result)
131
+ return 0
132
+
133
+
134
+ def _json_dumps(payload) -> str:
135
+ import json
136
+ return json.dumps(payload, indent=2)
137
+
138
+
139
+ def _search_to_camel(result: dict) -> dict:
140
+ """Explicit recursive camelCase mapping of the ``search_conversations`` result
141
+ (this command's own serializer — ``stamp_schema_version`` only inserts the
142
+ leading key). Top level then hit level, per spec §7."""
143
+ out = {
144
+ "query": result.get("query", ""),
145
+ "mode": result.get("mode"),
146
+ "hits": [_hit_to_camel(h) for h in result.get("hits", [])],
147
+ "total": result.get("total", 0),
148
+ "kind": result.get("kind"),
149
+ "searchDepth": result.get("search_depth"),
150
+ }
151
+ if result.get("filter_degraded"):
152
+ out["filterDegraded"] = True
153
+ return out
154
+
155
+
156
+ def _hit_to_camel(h: dict) -> dict:
157
+ return {
158
+ "sessionId": h.get("session_id"),
159
+ "uuid": h.get("uuid"),
160
+ "projectLabel": h.get("project_label"),
161
+ "title": h.get("title"),
162
+ "ts": h.get("ts"),
163
+ "snippet": h.get("snippet"),
164
+ "matchKinds": h.get("match_kinds", []),
165
+ "costUsd": h.get("cost_usd", 0.0),
166
+ }
167
+
168
+
169
+ def _render_search_table(result: dict) -> None:
170
+ hits = result.get("hits", [])
171
+ if not hits:
172
+ print("No matching transcripts.")
173
+ return
174
+ rows = []
175
+ for h in hits:
176
+ kinds = ",".join(h.get("match_kinds") or []) or "-"
177
+ rows.append([
178
+ h.get("session_id") or "",
179
+ h.get("ts") or "",
180
+ h.get("project_label") or "",
181
+ kinds,
182
+ (h.get("snippet") or "").strip(),
183
+ ])
184
+ table = _boxed_table(
185
+ ["Session", "When", "Project", "Kinds", "Snippet"], rows,
186
+ ["left", "left", "left", "left", "left"])
187
+ print(table)
188
+ total = result.get("total", len(hits))
189
+ depth = result.get("search_depth")
190
+ suffix = f" (search depth: {depth})" if depth and depth != "full" else ""
191
+ print(f"\n{len(hits)} of {total} match(es){suffix}")