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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.67.0] - 2026-07-12
9
+
10
+ ### Added
11
+ - Anonymized conversation sharing (#281 S4): the transcript reader gains an "Anon" toggle next to Export ▾ (on by default, remembered) that rewrites your observed project paths, home directory, and username to placeholders (`project-N`, `~`, `user`) and redacts a documented set of secret patterns (API keys, tokens, Authorization headers) before an export or per-card copy leaves your machine — with `--raw`/toggle-off restoring the exact unredacted output byte-for-byte; per-card copy is fail-closed (a scrub-plan failure leaves the clipboard untouched rather than copying raw); the guarantee is best-effort over known tokens, so review before sharing.
12
+ - New `cctally transcript export|search` command (#281 S4): `export <session-id>` prints a whole session as Markdown (anonymized by default, `--raw` for the exact export, byte-identical to the dashboard download), and `search <query>` runs the dashboard's cross-session search with its full filter surface and a `--json` mode from the terminal.
13
+ - CI safety net (#281 S2): the full frontend vitest estate now runs in CI — on every non-docs-only push to main (self-hosted lane) and in a new hosted-ubuntu `test-pr` lane covering same-repo code PRs plus all code PRs on the public repo — and a new markdown doc-lint (`bin/cctally-doc-lint-test`, auto-run by `cctally-test-all`, plus a `doc-lint.yml` workflow that sees md-only pushes) guards the retired stale claims from reappearing.
14
+ - Reader smoke net (#281 S3): the conversation reader now ships a committed Playwright real-browser test suite covering the scroll, reverse-paging anchor, live-tail "↓ N new" pill, cold jump-to-turn, find-bar focus trap, and modal scroll-lock seams that JSDOM unit tests structurally cannot exercise — wired into CI on both the self-hosted push lane and the hosted PR lane (which builds a fresh bundle first) so real-browser reader regressions are caught before release.
15
+ - Reader e2e net strengthened (#281 S5): 9 live reader scenarios including a genuine above-insertion reveal guard (jump to a late message of a large collapsed subagent thread, reveal the earlier messages, and the anchored message stays put) and a below-giants cold-jump guard.
16
+ - `cctally setup`'s first-run cache sync now shows live progress on the terminal — a pre-sync notice, per-file counts, and a background heartbeat so a large session history no longer looks hung during the initial ingest; progress is stderr-only and TTY-gated, so `--json` output and piped/non-interactive runs are unaffected (#281 S7).
17
+
18
+ ### Changed
19
+ - `cctally setup` now ends with the "restart Claude Code" step as a prominent `▶ Next step:` call-to-action (dropping the `⚠`) instead of burying it mid-output, and the README quick start plus the installation guide now clarify that the reporting commands (`daily`, `dashboard`, `tui`) work immediately after install — only auto-recording needs `cctally setup` (#281 S7).
20
+ - `forecast` (`$/1%` median) and `diff` (multi-week average) now reset-aware-floor a credited historical week's usage %, matching every other used-% surface; uncredited weeks are unchanged (#290).
21
+ - Conversation reader: Escape now steps back one level (reader → conversations list → dashboard) instead of ejecting straight to the dashboard, and an open outline sheet is peeled first (#289).
22
+ - Internal (#281 S5): the conversation reader's scroll / pager / live-tail orchestration is extracted into a pure, tested state-machine module plus a thin adapter, with no behavior change beyond the #285/#286 fixes below.
23
+ - Internal (#281 S9): the public-repo mirror switched from per-commit trailer-based replay to release-granular snapshot publishing — each release now appends one whole-tree `release vX.Y.Z` public commit (preserve+append, compare-and-swap, idempotent resume) and the per-commit trailer machinery (commit-msg hook, `--- public ---`/`Public-Skip:` grammar, message sanitizer, skip-chain metrics, ~5.8K maintainer-only lines) is retired; `.mirror-allowlist` + `_match.py` remain the unchanged privacy boundary. Maintainer-only; no user-facing behavior change.
24
+ - Internal (#281 S6): usage-% cross-surface invariants & structural guards — a new U-PCT reconcile-test group pins the usage-% chain (statusline 7d, `weekly` Used %, `report`/`forecast` current %, the dashboard envelope, `project`, and the `record-usage` write clamp) to one reset-aware value across control / mid-week-reset / manual-credit scenarios so a divergence between any two surfaces is caught; a stdlib-`ast` guard asserts every `MAX(weekly_percent)`-over-snapshots read routes through the reset-aware floor (or is a reasoned, issue-tracked exemption); and two guards assert the `bin/cctally` re-export manifest is complete (every name a sibling consumes is re-exported) and never eager-imports a private sibling outside a tolerate-absent guard. No behavior change.
25
+ - The `cctally tui` command is now documented as being in bugfix-only maintenance mode (#281 S10): both layout variants keep working and remain fully supported, but the terminal UI no longer tracks new dashboard features — use `cctally dashboard` for the actively-developed rich UI. No behavior change.
26
+ - Internal (#281 S11): the golden-file test harnesses now memoize their seeded SQLite fixture trees in a content-hash-keyed cache (`bin/_fixture_cache.py`), restoring instead of rebuilding whenever nothing that determines the fixture bytes has changed; the change is strictly additive and fail-safe — any cache problem falls back to running the builder, so a cache issue can never alter a test outcome (cache-on and `CCTALLY_FIXTURE_CACHE=0` produce byte-identical goldens) — with an opt-out (`CCTALLY_FIXTURE_CACHE=0`) and a completeness/relocatability audit (`CCTALLY_FIXTURE_CACHE_VERIFY=1`). Maintainer-only; no user-facing behavior change.
27
+
28
+ ### Fixed
29
+ - Internal (maintainer-only): the public-mirror snapshot publisher and its release-harness test no longer flake on a benign `git archive | tar` SIGPIPE — `tar` stops reading at the archive's end-of-archive marker and closes the pipe before `git archive` finishes writing its trailing record padding, so `git archive` is killed by SIGPIPE (returncode `-13`) while `tar` exits 0; that combination is now recognized as success (the extracted-manifest check remains the completeness backstop) rather than raising `archive|tar failed (archive=-13 tar=0)`. No user-facing behavior change.
30
+ - The Blocks panel's per-block cost bar now renders the **fable** model in its own rose colour instead of the default grey — the gauge segment was missing its `.seg-fable` colour rule while opus/sonnet/haiku had theirs (the legend dot was already correct).
31
+ - 5-hour block start and window times now round to the nearest 10-minute boundary for display everywhere they surface (the dashboard Blocks panel + block-detail modal, and the CLI `blocks` / `five-hour-blocks` / `five-hour-breakdown` tables), so a reset captured with sub-10-minute jitter as e.g. `10:39` shows as `10:40` instead — a long-standing display glitch; the stored timestamps stay exact, so block lookups and partitioning are unaffected.
32
+ - A find-jump into a message inside a collapsed subagent thread no longer yanks the reader to the bottom (#291): while any jump is navigating to a message, the reader disables react-virtuoso's resize-autoscroll-to-bottom watcher, so force-opening a large collapsed thread to reveal the target keeps the viewport on the target instead of snapping to the end.
33
+ - `transcript export` on very large caches no longer cold-starts slowly: the `cwd` lookup used for anonymization is now backed by a partial index, turning a full-table scan into an index-only lookup (#289).
34
+ - Single-page conversations open at the top again (#285): the reader passes react-virtuoso a literal `followOutput={false}` while a top-landing is in flight, so its raw-truthy resize-autoscroll watcher can no longer pull a single-page open to the bottom (multi-page tail opens still land at the bottom with live-tail engaged).
35
+ - Cold-tail outline/find jumps land reliably (#286): jumping to an earlier message from the bottom of a long conversation now waits for the loaded window to catch up before deciding a target is truly unreachable, instead of occasionally clearing the jump before the message finished loading, and walks the full distance to a far target in one pass.
36
+ - Docs truth pass (#281 S1): the README privacy tagline now discloses the anonymous, opt-out install-count telemetry instead of denying it (and the "nothing leaves your machine" absolute is qualified — `cctally never uploads your session data`); the CONTRIBUTING license line is corrected from MIT to Apache-2.0; `docs/architecture.md` and `docs/runtime-data.md` are rewritten to describe the real multi-module layout, the migration framework, and the full 15-table `stats.db` schema (plus the conversation-viewer cache tables); stale claims are fixed for the pricing-dict location, the `doctor` module attribution, the superseded `search_aux` conversation index, and the 5-hour anchor wording; the `blocks` / `five-hour-blocks` / `five-hour-breakdown` trio now cross-reference each other; and the `telemetry` command doc's "no IP ever leaves" claim is made precise.
37
+
8
38
  ## [1.66.0] - 2026-07-10
9
39
 
10
40
  ### Added
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  <a href="https://github.com/omrikais/cctally/stargazers"><img src="https://img.shields.io/github/stars/omrikais/cctally.svg" alt="GitHub stars"></a>
17
17
  </p>
18
18
 
19
- Claude Code's Pro/Max plans meter you on a weekly quota, but the only signal you get is a percentage that creeps up until you cap. `cctally` turns that percentage into something you can act on: it reads your local session logs, computes cost in-process, and shows you **how much each percent of quota is actually costing you, whether you're on track to cap this week, and exactly where the spend is going** - across a live web dashboard, a read-only conversation viewer, and a full set of CLI reports. Everything runs locally against your own `~/.claude` data. No account, no API key, no telemetry, nothing leaves your machine.
19
+ Claude Code's Pro/Max plans meter you on a weekly quota, but the only signal you get is a percentage that creeps up until you cap. `cctally` turns that percentage into something you can act on: it reads your local session logs, computes cost in-process, and shows you **how much each percent of quota is actually costing you, whether you're on track to cap this week, and exactly where the spend is going** - across a live web dashboard, a read-only conversation viewer, and a full set of CLI reports. Everything runs locally against your own `~/.claude` data. No account, no API key, no cloud processing — cctally never uploads your session data, and its only telemetry is an anonymous, opt-out [install count](#privacy--telemetry).
20
20
 
21
21
  <p align="center">
22
22
  <img src="docs/img/dashboard-desktop.png" alt="cctally dashboard, desktop view" width="900">
@@ -37,7 +37,9 @@ npm install -g cctally && cctally setup
37
37
  git clone https://github.com/omrikais/cctally && cd cctally && ./bin/cctally setup
38
38
  ```
39
39
 
40
- `cctally setup` symlinks the binaries into `~/.local/bin/`, adds three additive hooks to `~/.claude/settings.json` (it never overwrites existing entries), and bootstraps the local SQLite cache. If `~/.local/bin/` isn't on your `PATH`, the script prints the line to add. The npm install needs Python 3 on `PATH` - if setup reports "python3 not found", install it (`brew install python` on macOS) and re-run.
40
+ The reporting commands work right away — you don't need `cctally setup` first. Straight after an install, `npx cctally daily` (or, once it's global, `cctally daily`) reads your existing `~/.claude` session logs directly and prints a cost table; `cctally dashboard` and `cctally tui` work the same way. `cctally setup` is what adds *auto-recording* the live status-line quota percentage and the hooks that keep data flowing as you work — so run it once you want continuous tracking rather than on-demand reports.
41
+
42
+ `cctally setup` symlinks the binaries into `~/.local/bin/`, adds three additive hooks to `~/.claude/settings.json` (it never overwrites existing entries), and bootstraps the local SQLite cache — on a large session history that first-run sync now shows live progress instead of sitting silent. If `~/.local/bin/` isn't on your `PATH`, the script prints the line to add. The npm install needs Python 3 on `PATH` - if setup reports "python3 not found", install it (`brew install python` on macOS) and re-run.
41
43
 
42
44
  ```bash
43
45
  cctally setup --status # verify hooks + symlinks
@@ -120,7 +122,7 @@ Get a native desktop notification the moment you cross a percent milestone, so a
120
122
 
121
123
  ## 5-hour analytics
122
124
 
123
- Claude Code's quota also runs on rolling 5-hour windows. `cctally blocks` and `cctally five-hour-blocks` break usage down per window - anchored to the real API resets, not a re-sizable guess - with model and project rollups and cross-reset flags, and `cctally five-hour-breakdown` drills into the per-percent milestones inside a single block.
125
+ Claude Code's quota also runs on rolling 5-hour windows. `cctally blocks` and `cctally five-hour-blocks` break usage down per window - anchored to the real API resets where a recorded reset covers the window (`blocks` marks its heuristic fallback rows with `~`), not a re-sizable guess - with model and project rollups and cross-reset flags, and `cctally five-hour-breakdown` drills into the per-percent milestones inside a single block.
124
126
 
125
127
  <p align="center">
126
128
  <img src="docs/img/cli-five-hour-blocks.svg" alt="cctally five-hour-blocks: 5h analytics with model breakdown">
@@ -156,7 +158,7 @@ If you also use OpenAI's Codex CLI, `cctally codex daily / monthly / session` ar
156
158
 
157
159
  ## Privacy & telemetry
158
160
 
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.
161
+ 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. The first beat is held for at least 24 hours after first run, so you always have a window to opt out first.
160
162
 
161
163
  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
164
 
@@ -1671,6 +1671,82 @@ def _reset_aware_floor(
1671
1671
  return row[0] if row and row[0] else None
1672
1672
 
1673
1673
 
1674
+ def _floored_week_max(conn, rows):
1675
+ """Return {week_key -> per-week reset-aware-floored maximum weekly_percent}.
1676
+
1677
+ ``rows`` is an iterable of
1678
+ (week_key, week_start_date, week_start_at, week_end_at,
1679
+ captured_at_utc, weekly_percent).
1680
+
1681
+ Two-pass so floor resolution is independent of row order (#290): pass 1
1682
+ buckets rows per ``week_key`` and canonicalizes each week's first non-NULL
1683
+ (week_start_at, week_end_at) + its week_start_date; pass 2 resolves
1684
+ ``_reset_aware_floor`` once per week (keyed on week_start_date) and drops
1685
+ captures earlier than that floor before taking the week's maximum
1686
+ ``weekly_percent``. ``week_key`` is the caller's aggregation key (1:1 with a
1687
+ week); floor identity is week_start_date, so a NULL-bound legacy row cannot
1688
+ suppress the reset-event leg for a later anchored row of the same week.
1689
+
1690
+ A NULL ``weekly_percent`` row is skipped. An unparseable ``captured_at_utc``
1691
+ under an active floor is RETAINED (epoch unknown), matching
1692
+ ``_cctally_project._load_week_snapshots``. All-NULL bounds resolve
1693
+ credit-floor-leg-only (the reset leg is inert: unixepoch(NULL) is NULL).
1694
+ A week whose every in-scope row is pre-floor is absent from the result.
1695
+ """
1696
+ # Pass 1: bucket + canonicalize bounds.
1697
+ buckets: dict = {}
1698
+ for wk, wsd, ws_at, we_at, cap, pct in rows:
1699
+ if pct is None:
1700
+ continue
1701
+ b = buckets.get(wk)
1702
+ if b is None:
1703
+ buckets[wk] = {
1704
+ "wsd": wsd, "ws_at": ws_at, "we_at": we_at,
1705
+ "rows": [(cap, float(pct))],
1706
+ }
1707
+ continue
1708
+ if b["wsd"] is None and wsd is not None:
1709
+ b["wsd"] = wsd
1710
+ if b["ws_at"] is None and ws_at is not None:
1711
+ b["ws_at"] = ws_at
1712
+ if b["we_at"] is None and we_at is not None:
1713
+ b["we_at"] = we_at
1714
+ b["rows"].append((cap, float(pct)))
1715
+
1716
+ # Pass 2: resolve floor once per week, drop pre-floor captures, take max.
1717
+ result: dict = {}
1718
+ for wk, b in buckets.items():
1719
+ floor_iso = _reset_aware_floor(conn, b["wsd"], b["ws_at"], b["we_at"])
1720
+ floor_epoch = None
1721
+ if floor_iso:
1722
+ try:
1723
+ floor_epoch = int(
1724
+ parse_iso_datetime(
1725
+ floor_iso, "floored_week_max.floor"
1726
+ ).timestamp()
1727
+ )
1728
+ except ValueError:
1729
+ floor_epoch = None
1730
+ best = None
1731
+ for cap, pct in b["rows"]:
1732
+ if floor_epoch is not None and cap is not None:
1733
+ try:
1734
+ cap_epoch = int(
1735
+ parse_iso_datetime(
1736
+ str(cap), "floored_week_max.cap"
1737
+ ).timestamp()
1738
+ )
1739
+ except ValueError:
1740
+ cap_epoch = None
1741
+ if cap_epoch is not None and cap_epoch < floor_epoch:
1742
+ continue
1743
+ if best is None or pct > best:
1744
+ best = pct
1745
+ if best is not None:
1746
+ result[wk] = best
1747
+ return result
1748
+
1749
+
1674
1750
  def get_latest_usage_for_week(
1675
1751
  conn: sqlite3.Connection,
1676
1752
  week_ref: WeekRef,
@@ -349,7 +349,7 @@ from _lib_display_tz import (
349
349
  from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
350
350
  from _lib_fmt import stable_sum
351
351
  from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
352
- from _lib_five_hour import _canonical_5h_window_key
352
+ from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
353
353
  from _lib_subscription_weeks import _compute_subscription_weeks
354
354
  from _lib_blocks import _group_entries_into_blocks
355
355
  # #279 S2 F3: stdlib-logging chokepoint — server errors reach stderr via
@@ -528,6 +528,7 @@ from _cctally_dashboard_conversation import (
528
528
  _handle_get_conversation_outline_impl,
529
529
  _handle_get_conversation_prompts_impl,
530
530
  _handle_get_conversation_export_impl,
531
+ _handle_get_conversation_anon_map_impl,
531
532
  _handle_get_conversation_find_impl,
532
533
  _handle_get_conversation_media_impl,
533
534
  )
@@ -1787,8 +1788,13 @@ def _build_block_detail(block: "Block",
1787
1788
  if block.actual_end_time else None),
1788
1789
  "anchor": block.anchor,
1789
1790
  "is_active": bool(block.is_active and block.entries_count > 0),
1791
+ # ``label`` rounds the shown start to the nearest 10-min boundary
1792
+ # (reset-jitter normalization); ``start_at`` / ``end_at`` above stay
1793
+ # exact — they echo the canonical interval and the lookup matches
1794
+ # ``start_at`` by exact equality (issue #76).
1790
1795
  "label": format_display_dt(
1791
- block.start_time, display_tz, fmt="%H:%M %b %d", suffix=True,
1796
+ _round_to_ten_minutes(block.start_time),
1797
+ display_tz, fmt="%H:%M %b %d", suffix=True,
1792
1798
  ),
1793
1799
 
1794
1800
  "entries_count": block.entries_count,
@@ -3615,6 +3621,9 @@ _GET_ROUTES = (
3615
3621
  ("prefix+suffix", ("/api/conversation/", "/export"),
3616
3622
  "_handle_get_conversation_export",
3617
3623
  ("scope", "endpoint.conversation_export"), True),
3624
+ ("prefix+suffix", ("/api/conversation/", "/anon-map"),
3625
+ "_handle_get_conversation_anon_map",
3626
+ ("scope", "endpoint.conversation_anon_map"), True),
3618
3627
  ("prefix+suffix", ("/api/conversation/", "/prompts"),
3619
3628
  "_handle_get_conversation_prompts",
3620
3629
  ("scope", "endpoint.conversation_prompts"), True),
@@ -5161,6 +5170,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5161
5170
  def _handle_get_conversation_export(self, path: str) -> None:
5162
5171
  return _handle_get_conversation_export_impl(self, path)
5163
5172
 
5173
+ def _handle_get_conversation_anon_map(self, path: str) -> None:
5174
+ return _handle_get_conversation_anon_map_impl(self, path)
5175
+
5164
5176
  def _handle_get_conversation_find(self, path: str) -> None:
5165
5177
  return _handle_get_conversation_find_impl(self, path)
5166
5178
 
@@ -604,20 +604,41 @@ def _handle_get_conversation_export_impl(handler, path: str) -> None:
604
604
  Blob/filename, so no ``Content-Disposition`` is needed)."""
605
605
  if not handler._require_transcripts_allowed():
606
606
  return
607
+ import os as _os
607
608
  import urllib.parse as _u
609
+ qs = handler.path.partition("?")[2]
608
610
  session_id = _u.unquote(path[len("/api/conversation/"):-len("/export")])
609
- q = _u.parse_qs(handler.path.partition("?")[2])
611
+ q = _u.parse_qs(qs)
610
612
  scope = _qs_str(q, "scope", "all")
611
613
  if scope not in _CONV_EXPORT_SCOPES:
612
614
  handler._respond_json(400, {"error": f"unknown scope: {scope}"})
613
615
  return
616
+ # #281 S4: `anonymize` gets its OWN strict parse (the default parse_qs /
617
+ # _qs_str path silently drops blanks + first-picks duplicates). It must
618
+ # appear at most once and be literal `0` or `1`; blank / duplicate / any
619
+ # other spelling is a clean 400 HERE, BEFORE the kernel (the
620
+ # _run_conversation_query-collapses-kernel-exceptions-to-500 gotcha). Absent
621
+ # → raw (the R4 "raw export byte-unchanged" acceptance is structural).
622
+ avals = _u.parse_qs(qs, keep_blank_values=True).get("anonymize")
623
+ if avals is not None and (len(avals) != 1 or avals[0] not in ("0", "1")):
624
+ handler._respond_json(400, {"error": f"invalid anonymize: {avals}"})
625
+ return
626
+ anonymize = avals is not None and avals[0] == "1"
614
627
  if not session_id:
615
628
  handler.send_error(404, "conversation not found")
616
629
  return
630
+
631
+ def _kernel(conn):
632
+ cq = handler._conversation_query()
633
+ md = cq.get_conversation_export(conn, session_id, scope)
634
+ if md is None or not anonymize:
635
+ return md
636
+ anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
637
+ plan = cq.build_anon_plan_for_db(conn, home_dir=_os.path.expanduser("~"))
638
+ return anon.scrub_text(md, plan)
639
+
617
640
  ok, body = handler._run_conversation_query(
618
- lambda conn: handler._conversation_query().get_conversation_export(
619
- conn, session_id, scope),
620
- "/api/conversation/export")
641
+ _kernel, "/api/conversation/export")
621
642
  if not ok:
622
643
  return
623
644
  if body is None:
@@ -630,6 +651,44 @@ def _handle_get_conversation_export_impl(handler, path: str) -> None:
630
651
  handler.end_headers()
631
652
  handler.wfile.write(data)
632
653
 
654
+ def _handle_get_conversation_anon_map_impl(handler, path: str) -> None:
655
+ """``GET /api/conversation/<sid>/anon-map`` — the ``plan_to_wire`` JSON the
656
+ client-side scrubber executes for per-card copy (#281 S4, spec §5).
657
+
658
+ Same fail-closed transcript privacy gate as the sibling reader routes —
659
+ ``_require_transcripts_allowed()`` ONLY (no ``_check_origin_csrf``). Although
660
+ the plan is GLOBAL, the route probes existence (``conversation_exists``) and
661
+ 404s on an unknown ``<sid>``, matching sibling envelope discipline — never a
662
+ token dump for a non-session. Contains only tokens the same gated client
663
+ already sees raw, so no new information exposure. Matched BEFORE the bare
664
+ ``<id>`` detail catch-all in ``do_GET`` (route-ordering test asserts the
665
+ suffix does not fall through)."""
666
+ if not handler._require_transcripts_allowed():
667
+ return
668
+ import os as _os
669
+ import urllib.parse as _u
670
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/anon-map")])
671
+ if not session_id:
672
+ handler.send_error(404, "conversation not found")
673
+ return
674
+ anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
675
+
676
+ def _kernel(conn):
677
+ cq = handler._conversation_query()
678
+ if not cq.conversation_exists(conn, session_id):
679
+ return None
680
+ plan = cq.build_anon_plan_for_db(conn, home_dir=_os.path.expanduser("~"))
681
+ return anon.plan_to_wire(plan)
682
+
683
+ ok, body = handler._run_conversation_query(
684
+ _kernel, "/api/conversation/anon-map")
685
+ if not ok:
686
+ return
687
+ if body is None:
688
+ handler.send_error(404, "conversation not found")
689
+ return
690
+ handler._respond_json(200, body)
691
+
633
692
  def _handle_get_conversation_find_impl(handler, path: str) -> None:
634
693
  """``GET /api/conversation/<sid>/find?q=...&kind=...`` — in-conversation
635
694
  find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
@@ -2400,6 +2400,16 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2400
2400
  ON conversation_messages(source_path);
2401
2401
  CREATE INDEX IF NOT EXISTS idx_conv_turnkey
2402
2402
  ON conversation_messages(msg_id, req_id);
2403
+ -- #289: partial covering index on cwd. build_anon_plan_for_db sources
2404
+ -- anonymization tokens with `SELECT DISTINCT cwd FROM conversation_messages
2405
+ -- WHERE cwd IS NOT NULL AND cwd != ''`; without this index that DISTINCT is
2406
+ -- a full scan of the whole (prose-heavy) table (~20s cold on a 5.5 GB
2407
+ -- cache). The partial `(cwd)` index matches the WHERE exactly so the
2408
+ -- DISTINCT is answered by an ordered index-only walk. MIRRORS migration 021
2409
+ -- (base schema here for fresh/rebuilt caches, migration for existing ones).
2410
+ CREATE INDEX IF NOT EXISTS idx_conversation_messages_cwd
2411
+ ON conversation_messages(cwd)
2412
+ WHERE cwd IS NOT NULL AND cwd != '';
2403
2413
 
2404
2414
  -- #193: per-session AI-generated title, isolated from the six places
2405
2415
  -- that iterate conversation_messages. The explicit NOT NULL on the
@@ -3987,6 +3997,27 @@ def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
3987
3997
  lock_fh.close()
3988
3998
 
3989
3999
 
4000
+ @cache_migration("021_index_conversation_messages_cwd")
4001
+ def _021_index_conversation_messages_cwd(conn: sqlite3.Connection) -> None:
4002
+ """#289: partial covering index on conversation_messages(cwd) to collapse the
4003
+ full-table `SELECT DISTINCT cwd` scan in build_anon_plan_for_db (~20s cold on
4004
+ a 5.5 GB cache) into an index-only search.
4005
+
4006
+ Fresh installs never run this handler: the dispatcher stamps it WITHOUT
4007
+ running (the fresh-install branch), and `_apply_cache_schema` already created
4008
+ the index on the fresh DB. NO self-stamp — the dispatcher central-stamps on a
4009
+ clean return (#140). Run-twice safe: CREATE INDEX is IF NOT EXISTS. cache.db
4010
+ is re-derivable — `cache-sync --rebuild` is the escape hatch.
4011
+
4012
+ No flock/BEGIN IMMEDIATE (unlike 020): a single CREATE INDEX IF NOT EXISTS is
4013
+ atomic and touches no rows, so it needs no mutual-exclusion scaffolding.
4014
+ """
4015
+ conn.execute(
4016
+ "CREATE INDEX IF NOT EXISTS idx_conversation_messages_cwd "
4017
+ "ON conversation_messages(cwd) WHERE cwd IS NOT NULL AND cwd != ''"
4018
+ )
4019
+
4020
+
3990
4021
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3991
4022
 
3992
4023
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -847,9 +847,17 @@ def _format_block_start(iso: str, tz: "ZoneInfo | None") -> str:
847
847
  Renders as ``YYYY-MM-DD HH:MM <SUFFIX>`` where the suffix is the
848
848
  zone label per ``display_tz_label``. Naive inputs are treated as
849
849
  UTC; ``tz=None`` means "host-local via bare astimezone()".
850
+
851
+ The displayed time is rounded to the nearest 10-minute boundary to
852
+ normalize Anthropic reset-capture jitter (e.g. a :39 recorded reset
853
+ renders as :40). The stored ``block_start_at`` is unchanged.
850
854
  """
851
855
  _c = _cctally()
852
- return _c.format_display_dt(iso, tz, fmt="%Y-%m-%d %H:%M", suffix=True)
856
+ parsed = dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
857
+ return _c.format_display_dt(
858
+ _c._round_to_ten_minutes(parsed), tz,
859
+ fmt="%Y-%m-%d %H:%M", suffix=True,
860
+ )
853
861
 
854
862
 
855
863
  def _format_hhmm_in_tz(iso: str, tz: "ZoneInfo | None") -> str:
@@ -858,9 +866,16 @@ def _format_hhmm_in_tz(iso: str, tz: "ZoneInfo | None") -> str:
858
866
  Mirrors ``_format_block_start``'s tz resolution so paired start/end
859
867
  cells in the same row stay in the same zone. Naive inputs are
860
868
  treated as UTC; ``tz=None`` means host-local. No suffix.
869
+
870
+ Rounded to the nearest 10-minute boundary for the same reset-jitter
871
+ normalization as ``_format_block_start`` (the paired start/end cells
872
+ must round together or a 5h window renders as 4h59m/5h01m).
861
873
  """
862
874
  _c = _cctally()
863
- return _c.format_display_dt(iso, tz, fmt="%H:%M", suffix=False)
875
+ parsed = dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
876
+ return _c.format_display_dt(
877
+ _c._round_to_ten_minutes(parsed), tz, fmt="%H:%M", suffix=False,
878
+ )
864
879
 
865
880
 
866
881
  def _block_is_active(
@@ -359,40 +359,54 @@ def _select_dollars_per_percent(
359
359
  if p_now >= 10.0 and p_now > 0:
360
360
  return spent_usd / p_now, "this_week"
361
361
 
362
- # Path 2: trailing 4-week median.
363
- # Fetch all eligible prior weeks then Python-filter (legacy rows may carry
364
- # non-UTC offsets that break lexical compare against an ISO-UTC bound).
365
- rows = conn.execute(
366
- "SELECT week_start_at, week_end_at, MAX(weekly_percent) AS final_pct, "
367
- " MAX(captured_at_utc) AS latest_cap "
362
+ # Path 2: trailing 4-week median, reset-aware floored (#290).
363
+ import statistics
364
+ # Bounded candidate selection: the <=12 most-recent prior weeks. The median
365
+ # needs 4 eligible; 12 leaves margin so flooring a credited week below 1%
366
+ # can't starve it, while keeping this hot path (live forecast view +
367
+ # dashboard refresh) from materializing all history.
368
+ cand = conn.execute(
369
+ "SELECT DISTINCT week_start_at, week_end_at, week_start_date "
368
370
  "FROM weekly_usage_snapshots "
369
371
  "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL "
370
- "GROUP BY week_start_at "
371
- "HAVING MAX(weekly_percent) >= 1"
372
+ " AND datetime(week_start_at) < datetime(?) "
373
+ "ORDER BY datetime(week_start_at) DESC LIMIT 12",
374
+ (current_week_start.isoformat(),),
372
375
  ).fetchall()
373
- by_instant: dict[dt.datetime, dict] = {}
374
- for r in rows:
375
- try:
376
- ws = parse_iso_datetime(r[0], "week_start_at")
377
- we = parse_iso_datetime(r[1], "week_end_at")
378
- except ValueError:
379
- continue
380
- slot = by_instant.get(ws)
381
- final_pct = float(r[2])
382
- if slot is None:
383
- by_instant[ws] = {"we": we, "final_pct": final_pct}
384
- else:
385
- slot["final_pct"] = max(slot["final_pct"], final_pct)
386
-
376
+ we_by_ws: dict[dt.datetime, dt.datetime] = {}
377
+ rows_in: list = []
378
+ ws_iso_list = [row[0] for row in cand]
379
+ if ws_iso_list:
380
+ placeholders = ",".join("?" * len(ws_iso_list))
381
+ snap = conn.execute(
382
+ "SELECT week_start_date, week_start_at, week_end_at, "
383
+ " captured_at_utc, weekly_percent "
384
+ "FROM weekly_usage_snapshots "
385
+ "WHERE week_start_at IN (" + placeholders + ")",
386
+ ws_iso_list,
387
+ ).fetchall()
388
+ for wsd, ws_iso, we_iso, cap_iso, pct in snap:
389
+ if pct is None:
390
+ continue
391
+ try:
392
+ ws = parse_iso_datetime(ws_iso, "week_start_at")
393
+ we = parse_iso_datetime(we_iso, "week_end_at")
394
+ except ValueError:
395
+ continue
396
+ we_by_ws.setdefault(ws, we) # first-wins; all rows share one instant
397
+ rows_in.append((ws, wsd, ws_iso, we_iso, cap_iso, pct))
398
+ floored = c._floored_week_max(conn, rows_in) # {ws_instant -> floored max}
387
399
  eligible: list[tuple[dt.datetime, dt.datetime, float]] = [
388
- (ws, v["we"], v["final_pct"])
389
- for ws, v in by_instant.items()
390
- if ws < current_week_start and v["we"] < now_utc and v["final_pct"] >= 1.0
400
+ (ws, we_by_ws[ws], floored[ws])
401
+ for ws in floored
402
+ if ws in we_by_ws
403
+ and ws < current_week_start
404
+ and we_by_ws[ws] < now_utc
405
+ and floored[ws] >= 1.0
391
406
  ]
392
407
  eligible.sort(key=lambda x: x[0], reverse=True)
393
408
  prior = eligible[:4]
394
409
  if len(prior) >= 4:
395
- import statistics
396
410
  values: list[float] = []
397
411
  for ws, we, final_pct in prior:
398
412
  if use_weekref_cost_cache:
@@ -2400,6 +2400,96 @@ def _build_setup_parser(subparsers, name, *, help_text, xref=None):
2400
2400
  )
2401
2401
  sp.set_defaults(func=c.cmd_setup)
2402
2402
 
2403
+ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
2404
+ """Build the `transcript` parser (#281 S4) — nested export|search subgroup,
2405
+ following the `db` subgroup precedent (call-time `c = _cctally()` binding,
2406
+ both sub-actions dispatch to `c.cmd_transcript` on `transcript_action`)."""
2407
+ c = _cctally()
2408
+ t_parser = subparsers.add_parser(
2409
+ name,
2410
+ help=help_text,
2411
+ formatter_class=CLIHelpFormatter,
2412
+ description=textwrap.dedent(
2413
+ """\
2414
+ Export or search conversation transcripts from the local cache.
2415
+
2416
+ Subcommands:
2417
+ export Whole-session Markdown. ANONYMIZED BY DEFAULT (observed
2418
+ project paths/labels, home dir, and username → project-N/
2419
+ ~/user, plus documented secret patterns redacted). --raw
2420
+ disables the whole scrub, byte-identical to the dashboard's
2421
+ raw export. Best-effort over KNOWN tokens — review before
2422
+ sharing (see docs/commands/transcript.md).
2423
+ search Cross-session FTS/LIKE search. Output is RAW (a navigation
2424
+ surface, not a sharing artifact).
2425
+
2426
+ Examples:
2427
+ cctally transcript export <session-id>
2428
+ cctally transcript export <session-id> --scope chat --raw
2429
+ cctally transcript export <session-id> -o session.md
2430
+ cctally transcript search "reset window"
2431
+ cctally transcript search needle --kind prompts --json
2432
+ """
2433
+ ),
2434
+ epilog="See docs/commands/transcript.md for exit codes + the exact "
2435
+ "is/isn't-covered redaction list.",
2436
+ )
2437
+ t_sub = t_parser.add_subparsers(dest="transcript_action", required=True)
2438
+
2439
+ t_export = t_sub.add_parser(
2440
+ "export", help="Export a whole session as Markdown (anonymized by default)",
2441
+ formatter_class=CLIHelpFormatter)
2442
+ t_export.add_argument("session_id", metavar="SESSION_ID",
2443
+ help="Claude sessionId to export")
2444
+ t_export.add_argument(
2445
+ "--scope", choices=("all", "prompts", "chat", "recipe"), default="all",
2446
+ help="Which slice to export (default: all)")
2447
+ t_export.add_argument(
2448
+ "--raw", action="store_true",
2449
+ help="Disable the whole scrub (identity + secrets); byte-identical to "
2450
+ "the dashboard raw export")
2451
+ t_export.add_argument(
2452
+ "-o", "--output", metavar="PATH", default=None,
2453
+ help="Write to PATH instead of stdout (same exact bytes)")
2454
+ t_export.set_defaults(func=c.cmd_transcript)
2455
+
2456
+ t_search = t_sub.add_parser(
2457
+ "search", help="Search transcripts across sessions (raw output)",
2458
+ formatter_class=CLIHelpFormatter)
2459
+ t_search.add_argument("query", metavar="QUERY", help="Search text")
2460
+ t_search.add_argument(
2461
+ "--kind",
2462
+ choices=("all", "prompts", "assistant", "tools", "thinking",
2463
+ "title", "files"),
2464
+ default="all", help="Search facet (default: all)")
2465
+ t_search.add_argument("--limit", type=int, default=50,
2466
+ help="Max results (default: 50)")
2467
+ t_search.add_argument("--offset", type=int, default=0,
2468
+ help="Result offset for pagination (default: 0)")
2469
+ t_search.add_argument("--project", action="append", default=None,
2470
+ metavar="LABEL",
2471
+ help="Filter by project label (repeatable)")
2472
+ t_search.add_argument("--model", action="append", default=None,
2473
+ metavar="FAMILY",
2474
+ help="Filter by model family (repeatable)")
2475
+ t_search.add_argument("--date-from", dest="date_from", default=None,
2476
+ metavar="YYYY-MM-DD",
2477
+ help="Only sessions on/after this date (display tz)")
2478
+ t_search.add_argument("--date-to", dest="date_to", default=None,
2479
+ metavar="YYYY-MM-DD",
2480
+ help="Only sessions on/before this date (display tz)")
2481
+ t_search.add_argument("--cost-min", dest="cost_min", type=float, default=None,
2482
+ metavar="USD", help="Min session cost")
2483
+ t_search.add_argument("--cost-max", dest="cost_max", type=float, default=None,
2484
+ metavar="USD", help="Max session cost")
2485
+ t_search.add_argument("--rebuild-min", dest="rebuild_min", type=int,
2486
+ default=None, metavar="N",
2487
+ help="Min cache-rebuild count")
2488
+ t_search.add_argument("--json", action="store_true", dest="json",
2489
+ help="Emit JSON (schemaVersion: 1)")
2490
+ t_search.set_defaults(func=c.cmd_transcript)
2491
+
2492
+
2403
2493
  def _build_db_parser(subparsers, name, *, help_text, xref=None):
2404
2494
  """Build the `db` parser (registered via _REGISTRATION; #279 S6 W3).
2405
2495
 
@@ -2808,6 +2898,7 @@ _REGISTRATION = (
2808
2898
  _Reg('project', _build_project_parser, "Roll usage up by project (git-root), with per-project Used %% attribution", None, None),
2809
2899
  _Reg('diff', _build_diff_parser, "Compare Claude usage between two windows.", None, None),
2810
2900
  _Reg('session', _build_session_parser, "Show Claude usage grouped by sessionId (merges resumed-across-files sessions)", "Alias of `cctally claude session` (the canonical form).", None),
2901
+ _Reg('transcript', _build_transcript_parser, "Export or search conversation transcripts (anonymized export by default)", None, None),
2811
2902
  _Reg('claude', _build_claude_parser, "Claude-source reports (drop-in for `ccusage claude …`)", None, None),
2812
2903
  _Reg('codex', _build_codex_parser, "Codex-source reports (drop-in for `ccusage codex …`)", None, None),
2813
2904
  _Reg('config', _build_config_parser, "Get / set / unset persisted user preferences", None, None),