cctally 1.66.0 → 1.67.1
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 +35 -0
- package/README.md +6 -4
- package/bin/_cctally_core.py +76 -0
- package/bin/_cctally_dashboard.py +14 -2
- package/bin/_cctally_dashboard_conversation.py +63 -4
- package/bin/_cctally_db.py +31 -0
- package/bin/_cctally_five_hour.py +17 -2
- package/bin/_cctally_forecast.py +40 -26
- package/bin/_cctally_parser.py +91 -0
- package/bin/_cctally_project.py +10 -37
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_five_hour.py +27 -0
- package/bin/_lib_render.py +8 -1
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +8 -0
- package/bin/cctally-transcript +5 -0
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.67.1] - 2026-07-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Internal (maintainer-only): ship the golden-harness fixture-cache engine (`bin/_fixture_cache.py`, #281 S11) to the public repo. It is sourced by the mirrored `bin/_lib-fixture-cache.sh` wrapper (used by 18 public golden harnesses) and read by `tests/test_fixture_cache.py`, but was omitted from the mirror allowlist, so the public repo's CI failed at test collection with `FileNotFoundError`. A new structural guard now asserts no public test/harness references a mirror-private `bin/` sibling, so this stranded-dependency class is caught before it ships. No user-facing behavior change.
|
|
12
|
+
|
|
13
|
+
## [1.67.0] - 2026-07-12
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- `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).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `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).
|
|
25
|
+
- `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).
|
|
26
|
+
- 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).
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- 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.
|
|
35
|
+
- 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).
|
|
36
|
+
- 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.
|
|
37
|
+
- 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.
|
|
38
|
+
- `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).
|
|
39
|
+
- 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).
|
|
40
|
+
- 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.
|
|
41
|
+
- 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.
|
|
42
|
+
|
|
8
43
|
## [1.66.0] - 2026-07-10
|
|
9
44
|
|
|
10
45
|
### 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,
|
|
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`
|
|
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.
|
|
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
|
|
package/bin/_cctally_core.py
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
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
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -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
|
-
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
"
|
|
371
|
-
"
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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,
|
|
389
|
-
for ws
|
|
390
|
-
if ws
|
|
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:
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -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),
|