cctally 1.65.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 +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- 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 +13 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,69 @@ 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
|
+
|
|
38
|
+
## [1.66.0] - 2026-07-10
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- Reporting `--json` output now carries a `schemaVersion: 1` key (rendered first) so downstream consumers can version-gate against the payload shape. It is added to every reporting surface that lacked it — `daily`/`daily --instances`/`monthly`/`weekly`/`session`, the Codex `codex-daily`/`codex-monthly`/`codex-weekly`/`codex-session` reports, `blocks`, `forecast`, `report` (`dollar-per-percent`), `project`, `range-cost`, `cache-report`, `percent-breakdown`, `sync-week`, `telemetry`, and the `budget set`/`unset` actions — including their empty and error forms. The already-versioned surfaces (`five-hour-blocks`, `five-hour-breakdown`, `budget` status, `record-credit`, `pricing-check`) are unchanged, and the shipped snake-case `schema_version` surfaces (`diff`, `doctor`, `db status`, `refresh-usage`, `setup`) keep their existing key. Existing keys are unchanged and consumers should tolerate unknown keys (#279).
|
|
42
|
+
- The cache sync now counts JSONL lines it can't parse (malformed) or has to drift-skip (usage/model/timestamp no longer parse), per vendor, and surfaces them on the `cache-sync` done lines, the hook-tick log line, and a new `cctally doctor` "Ingest parse health" check — so a Claude Code / Codex session-format change that would silently affect your cost numbers becomes visible instead of vanishing (#279).
|
|
43
|
+
- `CCTALLY_DEBUG=1` now makes `cctally` print the full Python traceback on stderr when a command crashes (default output is unchanged), and `CCTALLY_DEBUG_LOG=<path>` additionally appends the backend log to a file — so a bug is reportable without editing source (#279).
|
|
44
|
+
- The dashboard now logs server errors (HTTP 500) to the terminal running it, instead of swallowing them silently; routine client errors and the per-request access log stay quiet (#279).
|
|
45
|
+
- `cctally doctor` gains a SQLite integrity check (`PRAGMA quick_check`, run only from the CLI where it's affordable — FAILs on stats.db corruption with safe recovery guidance, WARNs on the re-derivable cache.db) and an informational lock-state check that reports whether a sync lock is currently held (#279).
|
|
46
|
+
- `CCTALLY_PERF_TRACE=1 cctally cache-sync` now traces both the Claude and Codex ingest under one shared `cache-sync` phase tree, with the Codex sync carrying the same flock/discover/walk timing seams as the Claude sync (#279).
|
|
47
|
+
- New cache migration `020_session_entries_physical_unique` adds a `UNIQUE(source_path, line_offset)` backstop to the Claude session cache (matching the Codex table) so an offset-bookkeeping regression can never silently double-count your cost data — a collision fails loudly and rolls back that file instead. It dedups any pre-existing duplicates (keeping the first-ingested row) and runs automatically; `cache.db` is re-derivable, so `cache-sync --rebuild` remains the escape hatch (#279).
|
|
48
|
+
- `cctally pricing-check` now flags embedded-pricing allowlist suppressions that no longer correspond to a real LiteLLM divergence (`staleSuppressions`) or that are past a new optional `expires` cutover date (`expiredSuppressions`) — two additive `--json` arrays and an exit-1 actionable finding — and the weekly pricing-freshness workflow files them on its auto-tracked issue, so a deliberate pricing override can't silently ossify past its stated cutover (#279).
|
|
49
|
+
- Codex pricing now covers the `gpt-5.6` model family (`gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`), so cost for those models is computed from their own published rates instead of falling back to `gpt-5` pricing.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- `cctally project` and `cctally forecast` now exit `2` (was `1`) on their own usage/validation errors — a bad `--weeks`, `--weeks` combined with `--since`/`--until`, `--since > --until` or an unparseable date for `project`; `--json` together with `--status-line` or a bad `--targets` for `forecast` — matching the rest of the cctally-native command family (`diff`, `budget`, `five-hour-*`, `pricing-check`, `doctor`). The ccusage-parity commands (`daily`/`monthly`/`weekly`/`session`/`blocks`/`codex-*`) keep their exit `1` on bad dates for upstream parity. The exit-code convention is now documented in `docs/cli-contract.md` (#279).
|
|
53
|
+
- Release tooling (maintainer): the npm-publish poll now hard-fails on timeout with `--resume` guidance instead of silently reporting success; `--npm-soft-timeout` restores the old exit-0 behavior and marks the final line "published (npm pending verification)".
|
|
54
|
+
- Internal: the record write path (resets-at plausibility, weekly credit / reset-to-zero debounce, 5h in-place credit, the reset-aware HWM clamp + monotonic HWM files, 5h milestone ranges, and the projected-pace alert crossings) and the forecast projection now route their decision logic through pure, unit-tested kernels (`_lib_record.py`, `_lib_credit.py`, `_lib_forecast.py`, `_lib_five_hour.py`); no behavior change (#279).
|
|
55
|
+
- Internal: CLI-architecture consistency pass — `build_parser()` is now a loop over an ordered per-command registration table of builder functions (every subcommand's `--help` output is byte-for-byte unchanged, verified by a recursive sweep across the flat commands, the `claude`/`codex` subgroups, and the hidden commands); the four `_iso_z` UTC-Z serializers collapse to one canonical helper (doctor keeps its deliberately divergent copy); the `_eprint` / `_load_lib` / `_ensure_sibling_loaded` per-module bootstrap copies are normalized and pinned by AST drift-guard tests; and the three deviant thin bash wrappers are aligned to the shared template. No behavior change (#279).
|
|
56
|
+
- Internal: the dashboard server module (`_cctally_dashboard.py`, ~10.5K lines) is split into four consumer-only sibling modules (share, envelope, cache-report, conversation) and its HTTP request routing is now table-driven; every endpoint's response bytes and status codes are byte-identical, verified against a real data corpus (#279).
|
|
57
|
+
- `cctally report --json`'s `generatedAt` timestamp now routes through the same as-of clock as the rest of the report (unchanged in normal use — it still reflects wall-clock when no override is set), making the JSON output reproducible under a pinned clock (#279).
|
|
58
|
+
- Internal: test-infrastructure hardening — every migration in both registries now ships a per-migration golden with an enforced registry-completeness guard and a documented handler-idempotency contract; a new ancient-DB→head chain test covers migration interaction (the recompute gate + the FTS search-split→drop→title sequence); `pytest-timeout` is wired as an optional-by-detection dev dependency with a deliberate-hang canary and `timeout-minutes` on every CI job; the npm shim/postinstall harnesses now assert empty-output emptiness instead of skipping it; and `daily`, `monthly`, and `report` gain golden test harnesses (#279).
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
- `cctally setup` now installs the `cctally-budget` symlink (it existed but was never linked); a drift-guard test keeps the wrapper list and the installer in sync.
|
|
62
|
+
- `CCTALLY_ALLOW_PROD_MIGRATION=0`, `CCTALLY_DISABLE_DEV_AUTODETECT=0`, `CCTALLY_DEBUG=0`, and `CCTALLY_DISABLE_UPDATE_CHECK=0` now mean "disabled" — previously any value, including `0`, enabled these flags.
|
|
63
|
+
- A corrupt `stats.db` now produces a one-line diagnosis with recovery guidance and exit 2 instead of a raw traceback.
|
|
64
|
+
- Piping output to a closed reader (`cctally daily | head`) now exits 0 quietly instead of `Error: [Errno 32] Broken pipe`.
|
|
65
|
+
- The dashboard share endpoints now cap request bodies at 64 KiB and the dashboard server sets a 60s socket timeout (slow-loris hardening); server-sent-event streams treat a send timeout as a normal client disconnect.
|
|
66
|
+
- The dashboard `/api/data` endpoint now returns a JSON `500 {"error": "internal error"}` on an internal error instead of dropping the connection with no response, and a server-sent-event stream that fails mid-flight is now logged and closed cleanly instead of dying silently (#279).
|
|
67
|
+
- `cache.db` now opens with `PRAGMA synchronous=NORMAL` (a fully re-derivable database; fewer fsyncs during ingest).
|
|
68
|
+
- Codex resume tracking now persists the ingest iterator's actual dedup watermark (the cumulative token counter it compares against) rather than a reconstructed sum of per-turn deltas, closing a latent double-count/skip on rollouts whose cumulative and per-turn token accounting diverge; healthy sessions are unaffected and need no re-ingest (#279).
|
|
69
|
+
- The direct-JSONL fallback (used when `cache.db` can't be opened) now parses with the same single implementation as the cache path so the two can't drift, and a malformed `costUSD` value in a session line no longer aborts the read — it degrades to the token-derived cost like every other cost path (#279).
|
|
70
|
+
|
|
8
71
|
## [1.65.0] - 2026-07-09
|
|
9
72
|
|
|
10
73
|
### 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_alerts.py
CHANGED
|
@@ -44,7 +44,6 @@ from __future__ import annotations
|
|
|
44
44
|
|
|
45
45
|
import argparse
|
|
46
46
|
import datetime as dt
|
|
47
|
-
import importlib.util as _ilu
|
|
48
47
|
import os
|
|
49
48
|
import pathlib
|
|
50
49
|
import shutil
|
|
@@ -58,6 +57,7 @@ def _load_lib(name: str):
|
|
|
58
57
|
cached = sys.modules.get(name)
|
|
59
58
|
if cached is not None:
|
|
60
59
|
return cached
|
|
60
|
+
import importlib.util as _ilu
|
|
61
61
|
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
62
62
|
spec = _ilu.spec_from_file_location(name, p)
|
|
63
63
|
mod = _ilu.module_from_spec(spec)
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -100,7 +100,6 @@ from __future__ import annotations
|
|
|
100
100
|
import argparse
|
|
101
101
|
import datetime as dt
|
|
102
102
|
import fcntl
|
|
103
|
-
import importlib.util as _ilu
|
|
104
103
|
import json
|
|
105
104
|
import os
|
|
106
105
|
import pathlib
|
|
@@ -151,6 +150,7 @@ def _load_lib(name: str):
|
|
|
151
150
|
cached = sys.modules.get(name)
|
|
152
151
|
if cached is not None:
|
|
153
152
|
return cached
|
|
153
|
+
import importlib.util as _ilu
|
|
154
154
|
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
155
155
|
spec = _ilu.spec_from_file_location(name, p)
|
|
156
156
|
mod = _ilu.module_from_spec(spec)
|
|
@@ -229,7 +229,7 @@ def _conv_row_tuple(m, path_str):
|
|
|
229
229
|
)
|
|
230
230
|
|
|
231
231
|
|
|
232
|
-
def _iter_sync_entries(fh, path_str):
|
|
232
|
+
def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
|
|
233
233
|
"""Fused single-pass sync walker (#138). Yields
|
|
234
234
|
``(byte_offset, cost_or_None, msgrow_or_None, aititle_or_None)`` for each
|
|
235
235
|
JSONL line from ``fh``'s current position that produces a cost entry, a
|
|
@@ -269,11 +269,25 @@ def _iter_sync_entries(fh, path_str):
|
|
|
269
269
|
stripped = line.strip()
|
|
270
270
|
if not stripped:
|
|
271
271
|
continue
|
|
272
|
+
# #279 S2 F1: passive parse-health counters over the new-byte span.
|
|
273
|
+
# lines_seen counts non-blank lines (malformed included).
|
|
274
|
+
if stats is not None:
|
|
275
|
+
stats.lines_seen += 1
|
|
272
276
|
try:
|
|
273
277
|
obj = json.loads(stripped)
|
|
274
278
|
except json.JSONDecodeError:
|
|
279
|
+
if stats is not None:
|
|
280
|
+
stats.lines_malformed += 1
|
|
275
281
|
continue
|
|
276
282
|
cost = _lib_jsonl.parse_cost_entry(obj, path_str)
|
|
283
|
+
if cost is None and stats is not None:
|
|
284
|
+
# Assistant-typed line rejected for a NON-deliberate reason
|
|
285
|
+
# (schema-drift tripwire; <synthetic>/non-assistant are normal).
|
|
286
|
+
reason = _lib_jsonl.assistant_skip_reason(obj)
|
|
287
|
+
if reason is not None:
|
|
288
|
+
stats.assistant_lines_skipped += 1
|
|
289
|
+
stats.skip_reasons[reason] = \
|
|
290
|
+
stats.skip_reasons.get(reason, 0) + 1
|
|
277
291
|
mrow = _lib_conversation.parse_message_row(obj, offset)
|
|
278
292
|
ai = _lib_conversation.parse_ai_title(obj, offset)
|
|
279
293
|
if cost is not None or mrow is not None or ai is not None:
|
|
@@ -302,6 +316,77 @@ clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
|
302
316
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
303
317
|
|
|
304
318
|
|
|
319
|
+
_PARSE_HEALTH_SCHEMA = 1
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _update_parse_health_meta(
|
|
323
|
+
conn: sqlite3.Connection,
|
|
324
|
+
key: str,
|
|
325
|
+
*,
|
|
326
|
+
lines_seen: int,
|
|
327
|
+
lines_malformed: int,
|
|
328
|
+
lines_skipped: int,
|
|
329
|
+
skip_reasons: dict,
|
|
330
|
+
rebuild: bool,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Anomaly-delta-gated rolling parse-health record (#279 S2 F1 /
|
|
333
|
+
Codex P1-2). Writes ONLY when (a) this sync's malformed+skipped delta
|
|
334
|
+
is nonzero, (b) rebuild=True (baseline reset — write fresh from that
|
|
335
|
+
walk's counters), or (c) the key is absent (first adoption). Healthy
|
|
336
|
+
steady-state syncs — including the ~1s live-tail targeted ingests —
|
|
337
|
+
never write, so the cumulative ``lines_seen`` denominator advances
|
|
338
|
+
only at these writes ("as of the last write"); doctor reasons from
|
|
339
|
+
counts + recency, never a precise ratio.
|
|
340
|
+
|
|
341
|
+
Caller holds the sync flock. Runs at end-of-sync, OUTSIDE every
|
|
342
|
+
per-file ``[before, after]`` total_changes window, so
|
|
343
|
+
``stats.rows_changed`` stays byte-identical (#270); never bumps
|
|
344
|
+
``mutation_seq``. Commits its own write (mirrors the walk-complete
|
|
345
|
+
sentinel's commit discipline). Fail-soft: a corrupt prior value is
|
|
346
|
+
treated as absent.
|
|
347
|
+
"""
|
|
348
|
+
anomaly_delta = int(lines_malformed) + int(lines_skipped)
|
|
349
|
+
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
350
|
+
prior = None
|
|
351
|
+
try:
|
|
352
|
+
row = conn.execute(
|
|
353
|
+
"SELECT value FROM cache_meta WHERE key = ?", (key,)
|
|
354
|
+
).fetchone()
|
|
355
|
+
if row and row[0]:
|
|
356
|
+
loaded = json.loads(row[0])
|
|
357
|
+
if isinstance(loaded, dict):
|
|
358
|
+
prior = loaded
|
|
359
|
+
except (sqlite3.DatabaseError, ValueError):
|
|
360
|
+
prior = None
|
|
361
|
+
if not rebuild and prior is not None and anomaly_delta == 0:
|
|
362
|
+
return # steady state: zero-write
|
|
363
|
+
if rebuild or prior is None:
|
|
364
|
+
prior = {"lines_seen": 0, "lines_malformed": 0, "lines_skipped": 0,
|
|
365
|
+
"reasons": {}, "last_anomaly_at": None, "since": now_iso}
|
|
366
|
+
record = {
|
|
367
|
+
"schema": _PARSE_HEALTH_SCHEMA,
|
|
368
|
+
"lines_seen": int(prior.get("lines_seen", 0) or 0) + int(lines_seen),
|
|
369
|
+
"lines_malformed": (int(prior.get("lines_malformed", 0) or 0)
|
|
370
|
+
+ int(lines_malformed)),
|
|
371
|
+
"lines_skipped": (int(prior.get("lines_skipped", 0) or 0)
|
|
372
|
+
+ int(lines_skipped)),
|
|
373
|
+
"last_anomaly_at": (now_iso if anomaly_delta > 0
|
|
374
|
+
else prior.get("last_anomaly_at")),
|
|
375
|
+
"last_write_at": now_iso,
|
|
376
|
+
"since": prior.get("since") or now_iso,
|
|
377
|
+
}
|
|
378
|
+
reasons = dict(prior.get("reasons") or {}) \
|
|
379
|
+
if isinstance(prior.get("reasons"), dict) else {}
|
|
380
|
+
for r, n in (skip_reasons or {}).items():
|
|
381
|
+
reasons[r] = int(reasons.get(r, 0) or 0) + int(n)
|
|
382
|
+
record["reasons"] = reasons
|
|
383
|
+
try:
|
|
384
|
+
_set_cache_meta(conn, key, json.dumps(record, sort_keys=True))
|
|
385
|
+
conn.commit()
|
|
386
|
+
except sqlite3.DatabaseError:
|
|
387
|
+
conn.rollback() # observability must never fail the sync
|
|
388
|
+
|
|
389
|
+
|
|
305
390
|
# === BEGIN MOVED REGIONS ===
|
|
306
391
|
# Path constants APP_DIR / CACHE_DB_PATH / CACHE_LOCK_PATH /
|
|
307
392
|
# CACHE_LOCK_CODEX_PATH live in _cctally_core (promoted 2026-05-22, #84);
|
|
@@ -497,6 +582,16 @@ class IngestStats:
|
|
|
497
582
|
# and are otherwise unaffected.
|
|
498
583
|
files_failed: int = 0
|
|
499
584
|
deferred_reason: "str | None" = None
|
|
585
|
+
# #279 S2 F1 parse-health counters — passive observers over the new-byte
|
|
586
|
+
# span this sync walked. lines_seen counts non-blank lines (malformed
|
|
587
|
+
# included); assistant_lines_skipped counts assistant-typed lines
|
|
588
|
+
# parse_cost_entry rejected for a NON-deliberate reason (schema-drift
|
|
589
|
+
# tripwire; `<synthetic>` and non-assistant lines are normal). Reason
|
|
590
|
+
# vocabulary in _lib_jsonl._classify_cost_entry.
|
|
591
|
+
lines_seen: int = 0
|
|
592
|
+
lines_malformed: int = 0
|
|
593
|
+
assistant_lines_skipped: int = 0
|
|
594
|
+
skip_reasons: dict = field(default_factory=dict)
|
|
500
595
|
|
|
501
596
|
@property
|
|
502
597
|
def targeted_clean(self) -> bool:
|
|
@@ -1210,6 +1305,13 @@ def sync_cache(
|
|
|
1210
1305
|
|
|
1211
1306
|
with _perf.phase("discover") as _p_disc:
|
|
1212
1307
|
if targeted:
|
|
1308
|
+
# A requested path that vanished (session rotated/deleted
|
|
1309
|
+
# mid-live-tail) is deliberately DROPPED here without flagging
|
|
1310
|
+
# failure: marking files_failed would wedge the watch loop's
|
|
1311
|
+
# targeted_clean advance forever for a file that will never
|
|
1312
|
+
# return; the orphan-prune path owns its stale rows on the next
|
|
1313
|
+
# full sync. Pinned by tests/test_cache_accepted_behaviors.py
|
|
1314
|
+
# (#279 S3 F4).
|
|
1213
1315
|
paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
|
|
1214
1316
|
else:
|
|
1215
1317
|
paths = list(_iter_claude_jsonl_files())
|
|
@@ -1484,7 +1586,7 @@ def sync_cache(
|
|
|
1484
1586
|
# walk over the identical span — the "identical span"
|
|
1485
1587
|
# invariant is now structural (a single stop point) rather
|
|
1486
1588
|
# than a prose-enforced ``>= final_offset`` runtime break.
|
|
1487
|
-
for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str):
|
|
1589
|
+
for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str, stats=stats):
|
|
1488
1590
|
if cost is not None:
|
|
1489
1591
|
entry, msg_id, req_id = cost
|
|
1490
1592
|
usage = entry.usage
|
|
@@ -1756,6 +1858,17 @@ def sync_cache(
|
|
|
1756
1858
|
(dt.datetime.now(dt.timezone.utc).isoformat(),),
|
|
1757
1859
|
)
|
|
1758
1860
|
conn.commit()
|
|
1861
|
+
# #279 S2 F1: rolling parse-health record. Anomaly-delta-gated so
|
|
1862
|
+
# steady-state (incl. targeted live-tail) syncs stay zero-write;
|
|
1863
|
+
# targeted syncs still accumulate — they ingest real new bytes.
|
|
1864
|
+
_update_parse_health_meta(
|
|
1865
|
+
conn, "parse_health_claude",
|
|
1866
|
+
lines_seen=stats.lines_seen,
|
|
1867
|
+
lines_malformed=stats.lines_malformed,
|
|
1868
|
+
lines_skipped=stats.assistant_lines_skipped,
|
|
1869
|
+
skip_reasons=stats.skip_reasons,
|
|
1870
|
+
rebuild=rebuild,
|
|
1871
|
+
)
|
|
1759
1872
|
# At-rest hardening (Plan 2, spec §5). Runs here — at the end of the
|
|
1760
1873
|
# write transaction, while the cache.db.lock flock is still held (so a
|
|
1761
1874
|
# concurrent writer can't be mid-checkpoint) AND after at least one
|
|
@@ -2826,6 +2939,13 @@ class CodexIngestStats:
|
|
|
2826
2939
|
# $CODEX_HOME root set (issue #108 — a prior-root purge, not a delta).
|
|
2827
2940
|
files_pruned: int = 0
|
|
2828
2941
|
lock_contended: bool = False
|
|
2942
|
+
# #279 S2 F1 parse-health counters — folded from each file's
|
|
2943
|
+
# _CodexIterState after its drain. Same vocabulary as the iterator
|
|
2944
|
+
# (info-non-dict / no-last-token-usage / bad-timestamp / no-session-id).
|
|
2945
|
+
lines_seen: int = 0
|
|
2946
|
+
lines_malformed: int = 0
|
|
2947
|
+
token_events_skipped: int = 0
|
|
2948
|
+
skip_reasons: dict = field(default_factory=dict)
|
|
2829
2949
|
|
|
2830
2950
|
|
|
2831
2951
|
def _progress_codex_stderr(stats: CodexIngestStats, *, force: bool = False) -> None:
|
|
@@ -2863,7 +2983,10 @@ def sync_codex_cache(
|
|
|
2863
2983
|
|
|
2864
2984
|
lock_fh = open(_cctally_core.CACHE_LOCK_CODEX_PATH, "w")
|
|
2865
2985
|
try:
|
|
2866
|
-
|
|
2986
|
+
with _perf.phase("flock") as _p_flock:
|
|
2987
|
+
_acquired = _acquire_cache_flock(lock_fh, timeout=lock_timeout)
|
|
2988
|
+
_p_flock.set_meta(contended=not _acquired)
|
|
2989
|
+
if not _acquired:
|
|
2867
2990
|
eprint("[codex-cache] sync already in progress; using existing cache")
|
|
2868
2991
|
stats.lock_contended = True
|
|
2869
2992
|
return stats
|
|
@@ -2880,8 +3003,10 @@ def sync_codex_cache(
|
|
|
2880
3003
|
roots = _cctally()._codex_session_roots()
|
|
2881
3004
|
# Pure read (glob + is_file only); safe to run before the SELECT and
|
|
2882
3005
|
# the per-file loop, where no cache.db write lock may be held.
|
|
2883
|
-
|
|
2884
|
-
|
|
3006
|
+
with _perf.phase("discover") as _p_disc:
|
|
3007
|
+
paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
|
|
3008
|
+
stats.files_total = len(paths)
|
|
3009
|
+
_p_disc.set_count(len(paths))
|
|
2885
3010
|
|
|
2886
3011
|
# Scope the cache to the CURRENT root set: drop rows ingested under a
|
|
2887
3012
|
# prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
|
|
@@ -2942,6 +3067,11 @@ def sync_codex_cache(
|
|
|
2942
3067
|
)
|
|
2943
3068
|
}
|
|
2944
3069
|
|
|
3070
|
+
# #279 S2 F4: ONE coarse `walk` phase bracketing the per-file loop
|
|
3071
|
+
# (count = files_processed, never per-row — §2 rule). Manual CM so
|
|
3072
|
+
# the loop stays flat, mirroring sync_cache's walk seam.
|
|
3073
|
+
_p_walk = _perf.phase("walk")
|
|
3074
|
+
_p_walk.__enter__()
|
|
2945
3075
|
for jp in paths:
|
|
2946
3076
|
path_str = str(jp)
|
|
2947
3077
|
try:
|
|
@@ -2990,15 +3120,19 @@ def sync_codex_cache(
|
|
|
2990
3120
|
# ends on a metadata-only tail would lose the terminal
|
|
2991
3121
|
# session_id/model and the next resume would mis-attribute the
|
|
2992
3122
|
# first post-resume token_count.
|
|
3123
|
+
# #279 S3 F1: the state is BOTH the seed carrier for the dedup
|
|
3124
|
+
# watermark and the sink the iterator stamps it into. Seeding it
|
|
3125
|
+
# with initial_total_tokens (the prior resume's persisted
|
|
3126
|
+
# cumulative) means iter_state.total_tokens holds the guard's
|
|
3127
|
+
# terminal watermark once the iterator drains — which we persist
|
|
3128
|
+
# directly, replacing the former initial+Σ(per-turn) reconstruction
|
|
3129
|
+
# that could diverge from the true cumulative and double-count/skip
|
|
3130
|
+
# on the next resume.
|
|
2993
3131
|
iter_state = _CodexIterState(
|
|
2994
3132
|
session_id=initial_session_id,
|
|
2995
3133
|
model=initial_model,
|
|
3134
|
+
total_tokens=initial_total_tokens,
|
|
2996
3135
|
)
|
|
2997
|
-
# Track the cumulative `total_token_usage.total_tokens` across this
|
|
2998
|
-
# call. The iterator only yields when the cumulative strictly
|
|
2999
|
-
# advances by the current turn's `last_token_usage.total_tokens`,
|
|
3000
|
-
# so summing the per-turn totals reconstructs the final cumulative.
|
|
3001
|
-
running_total = initial_total_tokens
|
|
3002
3136
|
yielded_count = 0
|
|
3003
3137
|
try:
|
|
3004
3138
|
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
@@ -3023,9 +3157,18 @@ def sync_codex_cache(
|
|
|
3023
3157
|
entry.reasoning_output_tokens,
|
|
3024
3158
|
entry.total_tokens,
|
|
3025
3159
|
))
|
|
3026
|
-
running_total += int(entry.total_tokens or 0)
|
|
3027
3160
|
yielded_count += 1
|
|
3028
3161
|
final_offset = fh.tell()
|
|
3162
|
+
# #279 S2 F1: fold this file's iterator counters into the
|
|
3163
|
+
# sync-level stats. iter_state is per-file, so += is
|
|
3164
|
+
# exact. Folded inside the try: an OSError mid-read drops
|
|
3165
|
+
# this file's partial counters AND its offset advance
|
|
3166
|
+
# together, so re-walked lines are never double-counted.
|
|
3167
|
+
stats.lines_seen += iter_state.lines_seen
|
|
3168
|
+
stats.lines_malformed += iter_state.lines_malformed
|
|
3169
|
+
stats.token_events_skipped += iter_state.token_events_skipped
|
|
3170
|
+
for _r, _n in iter_state.skip_reasons.items():
|
|
3171
|
+
stats.skip_reasons[_r] = stats.skip_reasons.get(_r, 0) + _n
|
|
3029
3172
|
except OSError as exc:
|
|
3030
3173
|
eprint(f"[codex-cache] could not read {jp}: {exc}")
|
|
3031
3174
|
continue
|
|
@@ -3046,11 +3189,13 @@ def sync_codex_cache(
|
|
|
3046
3189
|
else initial_model
|
|
3047
3190
|
)
|
|
3048
3191
|
|
|
3049
|
-
# Persist the
|
|
3050
|
-
#
|
|
3051
|
-
#
|
|
3192
|
+
# Persist the iterator's stamped cumulative watermark if we yielded
|
|
3193
|
+
# this call (iter_state.total_tokens == the dedup guard's terminal
|
|
3194
|
+
# value by construction, #279 S3 F1). Otherwise preserve the prior
|
|
3195
|
+
# value — never overwrite with 0, which would re-enable
|
|
3196
|
+
# double-counting on the next resume.
|
|
3052
3197
|
new_last_total_tokens: int | None = (
|
|
3053
|
-
|
|
3198
|
+
iter_state.total_tokens if yielded_count > 0 else prev_total_tokens
|
|
3054
3199
|
)
|
|
3055
3200
|
|
|
3056
3201
|
# Python's sqlite3 module starts an implicit transaction on the
|
|
@@ -3102,6 +3247,21 @@ def sync_codex_cache(
|
|
|
3102
3247
|
|
|
3103
3248
|
if progress is not None:
|
|
3104
3249
|
progress(stats)
|
|
3250
|
+
_p_walk.__exit__(None, None, None)
|
|
3251
|
+
_p_walk.set_count(stats.files_processed)
|
|
3252
|
+
_p_walk.set_meta(skipped=stats.files_skipped_unchanged,
|
|
3253
|
+
rows=stats.rows_changed)
|
|
3254
|
+
# #279 S2 F1: rolling parse-health record (codex half). Same
|
|
3255
|
+
# anomaly-delta gate as the Claude tail; SQLite serializes the
|
|
3256
|
+
# cache.db write against a concurrent Claude sync.
|
|
3257
|
+
_update_parse_health_meta(
|
|
3258
|
+
conn, "parse_health_codex",
|
|
3259
|
+
lines_seen=stats.lines_seen,
|
|
3260
|
+
lines_malformed=stats.lines_malformed,
|
|
3261
|
+
lines_skipped=stats.token_events_skipped,
|
|
3262
|
+
skip_reasons=stats.skip_reasons,
|
|
3263
|
+
rebuild=rebuild,
|
|
3264
|
+
)
|
|
3105
3265
|
return stats
|
|
3106
3266
|
finally:
|
|
3107
3267
|
try:
|
|
@@ -3354,6 +3514,11 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
3354
3514
|
|
|
3355
3515
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
3356
3516
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
3517
|
+
# Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
|
|
3518
|
+
# power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
|
|
3519
|
+
# Fewer ingest fsyncs than the default FULL. Matches stats.db
|
|
3520
|
+
# (bin/_cctally_core.py open_db). #279 S1 F8.
|
|
3521
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
3357
3522
|
|
|
3358
3523
|
# Apply the shared cache.db schema (cctally-dev#93, D4): Claude tables +
|
|
3359
3524
|
# indexes, the session_id / project_path column adds on session_files
|
|
@@ -3454,6 +3619,13 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
3454
3619
|
lt = _REBUILD_LOCK_TIMEOUT_SECONDS if args.rebuild else None
|
|
3455
3620
|
contended = False
|
|
3456
3621
|
|
|
3622
|
+
# #279 S2 F4: one shared `cache-sync` root so a single flushed tree
|
|
3623
|
+
# carries BOTH vendors — with two sequential sync roots,
|
|
3624
|
+
# flush_stderr(current_root()) would show only the last one. Opened
|
|
3625
|
+
# after the --prune-orphans early returns so they can't leak a root.
|
|
3626
|
+
_p_root = _perf.phase("cache-sync")
|
|
3627
|
+
_p_root.__enter__()
|
|
3628
|
+
|
|
3457
3629
|
if source in ("claude", "all"):
|
|
3458
3630
|
with _perf.phase("sync_cache"):
|
|
3459
3631
|
stats = sync_cache(
|
|
@@ -3471,13 +3643,17 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
3471
3643
|
f"[cache-sync] claude done: {stats.files_processed} processed, "
|
|
3472
3644
|
f"{stats.files_skipped_unchanged} skipped, "
|
|
3473
3645
|
f"{stats.files_reset_truncated} reset, "
|
|
3474
|
-
f"{stats.rows_changed} rows changed"
|
|
3646
|
+
f"{stats.rows_changed} rows changed, "
|
|
3647
|
+
f"{stats.lines_malformed} malformed, "
|
|
3648
|
+
f"{stats.assistant_lines_skipped} drift-skipped"
|
|
3475
3649
|
)
|
|
3476
3650
|
|
|
3477
3651
|
if source in ("codex", "all"):
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3652
|
+
with _perf.phase("sync_codex_cache"):
|
|
3653
|
+
stats = sync_codex_cache(
|
|
3654
|
+
conn, progress=_progress_codex_stderr, rebuild=args.rebuild,
|
|
3655
|
+
lock_timeout=lt,
|
|
3656
|
+
)
|
|
3481
3657
|
_progress_codex_stderr(stats, force=True)
|
|
3482
3658
|
if stats.lock_contended and args.rebuild:
|
|
3483
3659
|
eprint(
|
|
@@ -3490,11 +3666,16 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
3490
3666
|
f"[cache-sync] codex done: {stats.files_processed} processed, "
|
|
3491
3667
|
f"{stats.files_skipped_unchanged} skipped, "
|
|
3492
3668
|
f"{stats.files_reset_truncated} reset, "
|
|
3493
|
-
f"{stats.rows_changed} rows changed"
|
|
3669
|
+
f"{stats.rows_changed} rows changed, "
|
|
3670
|
+
f"{stats.lines_malformed} malformed, "
|
|
3671
|
+
f"{stats.token_events_skipped} drift-skipped"
|
|
3494
3672
|
)
|
|
3495
3673
|
|
|
3496
|
-
|
|
3674
|
+
_p_root.__exit__(None, None, None)
|
|
3675
|
+
# #276 perf: when tracing is enabled, flush the completed "cache-sync"
|
|
3497
3676
|
# phase tree to stderr (stdout stays byte-identical). No-op when off.
|
|
3677
|
+
# As of #279 S2 F4 the root carries both the sync_cache and
|
|
3678
|
+
# sync_codex_cache children, so one flushed tree covers both vendors.
|
|
3498
3679
|
if _perf.enabled():
|
|
3499
3680
|
_perf.flush_stderr(_perf.current_root())
|
|
3500
3681
|
return 1 if contended else 0
|
|
@@ -1010,7 +1010,7 @@ def _emit_cache_report_json(
|
|
|
1010
1010
|
for row_dict in output[top_key]:
|
|
1011
1011
|
row_dict["totalCost"] = row_dict["cost"]
|
|
1012
1012
|
output["totals"]["totalCost"] = output["totals"]["cost"]
|
|
1013
|
-
return json.dumps(output, indent=2)
|
|
1013
|
+
return json.dumps(_cctally().stamp_schema_version(output), indent=2)
|
|
1014
1014
|
|
|
1015
1015
|
|
|
1016
1016
|
def _build_cache_report_title(args: argparse.Namespace, mode: str) -> str:
|
|
@@ -1141,8 +1141,9 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1141
1141
|
if since == until:
|
|
1142
1142
|
if args.json:
|
|
1143
1143
|
print(json.dumps(
|
|
1144
|
-
|
|
1145
|
-
|
|
1144
|
+
c.stamp_schema_version(
|
|
1145
|
+
{top_key: [], "totals": None,
|
|
1146
|
+
"generatedAt": now_utc_iso(now_utc=now_utc)}),
|
|
1146
1147
|
indent=2,
|
|
1147
1148
|
))
|
|
1148
1149
|
else:
|
|
@@ -1163,8 +1164,9 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1163
1164
|
if not rows:
|
|
1164
1165
|
if args.json:
|
|
1165
1166
|
print(json.dumps(
|
|
1166
|
-
|
|
1167
|
-
|
|
1167
|
+
c.stamp_schema_version(
|
|
1168
|
+
{top_key: [], "totals": None,
|
|
1169
|
+
"generatedAt": now_utc_iso(now_utc=now_utc)}),
|
|
1168
1170
|
indent=2,
|
|
1169
1171
|
))
|
|
1170
1172
|
else:
|