cctally 1.27.0 → 1.28.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 +27 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +278 -6
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +230 -41
- package/bin/_cctally_dashboard.py +399 -37
- package/bin/_cctally_db.py +594 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +273 -28
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +328 -50
- package/bin/_cctally_setup.py +7 -3
- package/bin/_cctally_statusline.py +8 -0
- package/bin/_cctally_update.py +3 -3
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +8 -1
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +162 -0
- package/bin/_lib_conversation_query.py +524 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +40 -1
- package/dashboard/static/assets/{index-D34qf0LE.css → index-Bj5ckRUE.css} +1 -1
- package/dashboard/static/assets/index-Dw4G5FD9.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.28.0] - 2026-06-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`cctally budget` now supports per-vendor budgets over configurable calendar periods.** The Claude budget can run over a `calendar-week` or `calendar-month` instead of the default subscription week (`cctally budget set 300 --period calendar-month`, or `cctally config set budget.period calendar-month`), and a separate **Codex (OpenAI) budget** tracks Codex's *actual API dollars* over a calendar week or month (`cctally budget set 200 --vendor codex --period calendar-month`). The two budgets are independent — configure either, both, or neither — and there is no combined cross-vendor cap (Claude is equivalent-$, Codex is actual-$, so they are never summed). The status report renders a labeled block per configured vendor (Claude first, then Codex) with a cost-basis parenthetical (`— equivalent-$` / `— actual API $`); the legacy single-vendor subscription-week output stays byte-identical. `cctally budget set/unset` gain `--vendor {claude,codex}` and `--period {subscription-week,calendar-week,calendar-month}` (short spellings `sub-week`/`week`/`month` normalize); `--json` gains an always-present `period` key and, when a Codex budget is configured, an additive `codex` sibling object (both additive — no schema-version bump). Calendar and Codex budgets no longer depend on weekly usage snapshots, so a fresh machine with a configured Codex budget renders `$0`/`0%` rather than "no usage data yet this week". Codex spend reconciles to the `codex-*` reports within 1e-9 USD.
|
|
12
|
+
- **A new `codex_budget` desktop-alert axis fires once per threshold as Codex actual spend crosses that percent of the Codex budget** (opt-in via `budget.codex.alerts_enabled`, default off), with the same forward-only / fire-once / reconcile-on-set latching as the Claude budget axis and re-arming each calendar period. Because Codex usage never flows through Claude's `record-usage`, the axis fires both from every Claude hook-tick and opportunistically whenever you run `cctally budget` (so a pure-Codex user still gets a push on their next `cctally` invocation). The Claude `budget` axis is also period-generalized so calendar-period Claude alerts fire correctly. In the local web dashboard, fired Codex alerts appear in the Recent-alerts panel/modal and as a toast with a distinct **CODEX** chip and a period-aware label ("Month of …" / "Calendar week of …" instead of always "Week"); the same period-aware label fix applies to calendar-period Claude budget alerts. Preview the axis end-to-end with `cctally alerts test --axis codex-budget`.
|
|
13
|
+
- **Projected-pace budget alerts now cover calendar-period Claude budgets and Codex budgets.** Previously the `projected` alert axis was subscription-week + Claude-only; it now fires an on-pace-to-exceed alert for any Claude period (`calendar-week` / `calendar-month`, opt-in via `budget.projected_enabled`) and for Codex budgets (opt-in via `budget.codex.projected_enabled`, which — like the Claude toggle — requires `budget.codex.alerts_enabled` to also be on). Codex projected crossings fire from `record-usage` and opportunistically whenever you run `cctally budget`, and re-arm each period; the fired projection reconciles to `cctally budget --json` `week_avg_projection_usd` within 1e-9 USD. Preview either with `cctally alerts test --axis projected --metric {budget_usd,codex_budget_usd}`.
|
|
14
|
+
- **The local web dashboard can now toggle the two Codex budget alert switches from Settings.** The Settings overlay (key `s`) gains "Codex budget alerts" (`budget.codex.alerts_enabled`) and "Codex projected-pace alerts" (`budget.codex.projected_enabled`); both write through a nested partial-merge so flipping a toggle never clobbers the Codex amount, period, or thresholds (those stay CLI-only). When no Codex budget is configured the toggles render disabled with a one-line hint pointing at `cctally budget set 200 --vendor codex`. Codex projected-pace crossings render on the dashboard with the **PROJECTED** chip and a vendor-tagged context line ("projected $230 of $200 · Codex").
|
|
15
|
+
- These two additions resolve the deferred follow-ups noted in the prior calendar-period + Codex budgets work (issues #134 and #135).
|
|
16
|
+
- **The local web dashboard can optionally serve read-only Claude/Codex conversation transcripts through three new JSON endpoints** (`/api/conversations`, `/api/conversation/<id>`, `/api/conversation/search`), behind a new opt-in `dashboard.expose_transcripts` config key (default off). Transcripts are double-gated — never served unless you have explicitly opted in AND the request Host is loopback-allowed — so a LAN-exposed dashboard (`--host 0.0.0.0`) never leaks conversation text by default. This release ships the endpoints and access gate only; there is no transcript-viewer UI yet.
|
|
17
|
+
- **`cctally doctor` gains a `db.version_ahead` check, and `cctally db recover` can now self-heal an ahead `cache.db`.** The check warns when a local DB's schema `user_version` has drifted ahead of the running binary (the "unreleased-head poisoning" hazard of running a newer checkout against your data dir and then downgrading); `cctally db recover` rebuilds an ahead `cache.db` losslessly from source JSONL (the cache is fully re-derivable) instead of leaving the binary stuck on `DowngradeDetected` (#145).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **`cache.db` and its lock/WAL sidecars are now created with owner-only permissions (files `0600`, the data dir `0700`).** Conversation transcripts can flow through the cache, so this keeps that data from being world-readable on shared machines.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **The weekly trend (`cctally report` / `dollar-per-percent` / `weekly`) no longer splits a past week into a spurious zero-width row from a single transient `0%` reading.** The historical reset-event backfill was applying the lenient "reset-to-zero" discriminator (a sub-25pp drop to ~0%, intended for *live* current-week detection where a debounce filters transient API zeros) to its one-shot scan over all past snapshots — which has no debounce. A single stale-replica `0%` blip mid-week (e.g. usage climbing `6% → 0% → 1%` on the same still-future week boundary) was therefore mis-read as a goodwill credit and segmented that historical week into a degenerate `09:00 → 09:00` zero-width row with duplicated/misattributed percentages and cost. The backfill now fires only on the unambiguous `≥25pp` drop; the reset-to-zero signal remains active for live detection (so a real surprise reset on the current week is still caught). On upgrade, any week already mis-split this way renders correctly again on the next read (the spurious event stops regenerating).
|
|
24
|
+
- Refuse to forward-migrate the prod data dir (`~/.local/share/cctally`) when running from a git checkout, preventing a dev/worktree binary from bricking the installed release with `DowngradeDetected`; override with `CCTALLY_ALLOW_PROD_MIGRATION=1` (#142).
|
|
25
|
+
- **`cctally db recover --db stats` now refuses to recover the production stats DB when run from a dev/git checkout** (exit 2, the DB left untouched), matching the prod-migration guard, so a worktree binary can't rewrite the installed instance's stats history (#146).
|
|
26
|
+
|
|
27
|
+
## [1.27.1] - 2026-06-04
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **Three Linux-only bugs, surfaced by running the full test suite on Linux for the first time** (cctally is developed on macOS, whose case-insensitive filesystem and BSD tooling had masked them): (1) `cctally statusline` with `display.tz = utc` no longer prints a spurious `invalid timezone 'utc'; using 'UTC'` warning on Linux — the lowercase `utc` preference is now normalized to the portable IANA key `UTC` before resolution (Linux's case-sensitive zoneinfo rejects `"utc"` where macOS silently accepts it); (2) the local web dashboard's background update-check thread no longer crashes with `TypeError: 'Event' object is not callable` when the dashboard shuts down — its internal stop-event field was shadowing `threading.Thread._stop`, which `Thread.join()` invokes during teardown; (3) `cctally setup --uninstall` now reliably terminates a running legacy usage-poller on Linux even when it was launched from a long filesystem path — the process-identity check passes `ps -ww` (unlimited-width output) so Linux's default ~80-column truncation can't drop the identifying token and mis-read the live daemon as a dead PID. No action needed on upgrade.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- **Internal (no user-facing change): the full test suite (`bin/cctally-test-all`) is now Linux-portable, and a GitHub-hosted Linux matrix (Ubuntu × Python 3.11/3.12/3.13) runs it on every tag, weekly, and on demand** — closing the cross-version verification gap left when the floor was lowered to 3.11 in 1.27.0. The portability work fixed interpreter- and OS-divergences that only ever ran on macOS before: a `pytest-xdist` timezone leak (one test pinned a Pacific `tzset()` whose libc state outlived `monkeypatch`, flipping later tests' date boundaries — now reset suite-wide via an autouse `conftest` fixture), terminal-width-dependent block-gap rendering under `COLUMNS=80`, Python 3.13's `~~~^^^` traceback caret-anchors in a migration golden, the macOS-`osascript`-vs-Linux-`notify-send` notifier-dispatch assumptions, a BSD-vs-util-linux `script` PTY invocation in the update-banner harness (now a portable `pty.spawn`), a host-config leak in the dashboard-envelope golden, and a detached background update-check that raced fixture teardown. (#132)
|
|
34
|
+
|
|
8
35
|
## [1.27.0] - 2026-06-04
|
|
9
36
|
|
|
10
37
|
### Changed
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -71,12 +71,14 @@ _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
|
71
71
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
72
72
|
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
73
73
|
_alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
|
|
74
|
+
_alert_text_codex_budget = _lib_alerts_payload._alert_text_codex_budget
|
|
74
75
|
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
75
76
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
76
77
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
77
78
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
78
79
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
79
80
|
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
|
|
81
|
+
_build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
|
|
80
82
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
81
83
|
|
|
82
84
|
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
@@ -175,6 +177,8 @@ def _dispatch_alert_notification(
|
|
|
175
177
|
title, subtitle, body = _alert_text_budget(payload, tz)
|
|
176
178
|
elif axis == "project_budget":
|
|
177
179
|
title, subtitle, body = _alert_text_project_budget(payload, tz)
|
|
180
|
+
elif axis == "codex_budget":
|
|
181
|
+
title, subtitle, body = _alert_text_codex_budget(payload, tz)
|
|
178
182
|
elif axis == "projected":
|
|
179
183
|
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
180
184
|
else:
|
|
@@ -249,6 +253,7 @@ def _dispatch_alert_notification(
|
|
|
249
253
|
ctx.get("week_start_date")
|
|
250
254
|
or ctx.get("five_hour_window_key")
|
|
251
255
|
or ctx.get("week_start_at")
|
|
256
|
+
or ctx.get("period_start_at")
|
|
252
257
|
or ""
|
|
253
258
|
)
|
|
254
259
|
line = (
|
|
@@ -285,6 +290,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
285
290
|
axis = "budget"
|
|
286
291
|
elif args.axis == "project-budget":
|
|
287
292
|
axis = "project_budget"
|
|
293
|
+
elif args.axis == "codex-budget":
|
|
294
|
+
axis = "codex_budget"
|
|
288
295
|
elif args.axis == "projected":
|
|
289
296
|
axis = "projected"
|
|
290
297
|
else:
|
|
@@ -335,17 +342,35 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
335
342
|
spent_usd=26.0,
|
|
336
343
|
consumption_pct=104.0,
|
|
337
344
|
)
|
|
345
|
+
elif axis == "codex_budget":
|
|
346
|
+
# Synthetic Codex budget payload — NO DB writes (test/real divergence
|
|
347
|
+
# contract), NO real budget.codex entry required. A $200 calendar-month
|
|
348
|
+
# budget reads plausibly; spent scaled to the threshold so the body line
|
|
349
|
+
# reads as the at-crossing snapshot the dashboard would render.
|
|
350
|
+
payload = _build_alert_payload_codex_budget(
|
|
351
|
+
threshold=threshold,
|
|
352
|
+
crossed_at_utc=now_utc_iso(),
|
|
353
|
+
period_start_at=dt.date.today().replace(day=1).isoformat(),
|
|
354
|
+
period="calendar-month",
|
|
355
|
+
budget_usd=200.0,
|
|
356
|
+
spent_usd=200.0 * threshold / 100.0,
|
|
357
|
+
consumption_pct=float(threshold),
|
|
358
|
+
)
|
|
338
359
|
elif axis == "projected":
|
|
339
360
|
# Synthetic projected-pace payload — NO DB writes (test/real divergence
|
|
340
361
|
# contract). The metric discriminator picks the wiring; projected_value
|
|
341
362
|
# is the threshold's denominator-relative value (so the body reads
|
|
342
363
|
# plausibly, e.g. weekly 100% → "~100% of cap", budget 100% → "$300 of
|
|
343
364
|
# $300"). denominator is the at-crossing target the row would carry
|
|
344
|
-
# (Codex P0-4): 100.0 for weekly_pct, $300 for budget_usd
|
|
365
|
+
# (Codex P0-4): 100.0 for weekly_pct, $300 for budget_usd, $200 for
|
|
366
|
+
# codex_budget_usd (matching the codex_budget axis test-alert budget).
|
|
345
367
|
metric = getattr(args, "metric", "weekly_pct")
|
|
346
368
|
if metric == "budget_usd":
|
|
347
369
|
denominator = 300.0
|
|
348
370
|
projected_value = 300.0 * threshold / 100.0
|
|
371
|
+
elif metric == "codex_budget_usd":
|
|
372
|
+
denominator = 200.0
|
|
373
|
+
projected_value = 200.0 * threshold / 100.0
|
|
349
374
|
else: # weekly_pct
|
|
350
375
|
denominator = 100.0
|
|
351
376
|
projected_value = float(threshold)
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -167,6 +167,43 @@ _iter_codex_jsonl_entries_with_offsets = _lib_jsonl._iter_codex_jsonl_entries_wi
|
|
|
167
167
|
_parse_usage_entries = _lib_jsonl._parse_usage_entries
|
|
168
168
|
_should_replace = _lib_jsonl._should_replace
|
|
169
169
|
|
|
170
|
+
# Conversation-message parser kernel (Plan 1). Pure leaf (stdlib-only), so
|
|
171
|
+
# it loads at module-load time alongside _lib_jsonl. ``sync_cache``'s second
|
|
172
|
+
# seek-and-walk and the backfill walker both call ``_iter_message_rows``.
|
|
173
|
+
_lib_conversation = _load_lib("_lib_conversation")
|
|
174
|
+
_iter_message_rows = _lib_conversation.iter_message_rows
|
|
175
|
+
|
|
176
|
+
# Shared by sync_cache's second seek-and-walk AND backfill_conversation_messages
|
|
177
|
+
# so the column list, placeholders, and tuple order live in ONE place — a column
|
|
178
|
+
# add/reorder can't silently desync the two ingest paths (which would land
|
|
179
|
+
# values in the wrong columns on whichever path was missed).
|
|
180
|
+
_CONV_INSERT_SQL = (
|
|
181
|
+
"INSERT OR IGNORE INTO conversation_messages"
|
|
182
|
+
"(session_id,uuid,parent_uuid,source_path,byte_offset,"
|
|
183
|
+
" timestamp_utc,entry_type,text,blocks_json,model,msg_id,"
|
|
184
|
+
" req_id,cwd,git_branch,is_sidechain)"
|
|
185
|
+
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _conv_row_tuple(m, path_str):
|
|
190
|
+
"""Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order."""
|
|
191
|
+
return (
|
|
192
|
+
m.session_id, m.uuid, m.parent_uuid, path_str, m.byte_offset,
|
|
193
|
+
m.timestamp_utc, m.entry_type, m.text, m.blocks_json, m.model,
|
|
194
|
+
m.msg_id, m.req_id, m.cwd, m.git_branch, m.is_sidechain,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _iter_claude_jsonl_files():
|
|
199
|
+
"""Yield every Claude transcript ``*.jsonl`` under each data dir's
|
|
200
|
+
``projects/`` tree. Shared by ``sync_cache`` and the conversation backfill
|
|
201
|
+
so both ingest paths enumerate the IDENTICAL file set."""
|
|
202
|
+
for claude_dir in _get_claude_data_dirs():
|
|
203
|
+
for jp in (claude_dir / "projects").glob("**/*.jsonl"):
|
|
204
|
+
if jp.is_file():
|
|
205
|
+
yield jp
|
|
206
|
+
|
|
170
207
|
_cctally_db_sib = _load_lib("_cctally_db")
|
|
171
208
|
add_column_if_missing = _cctally_db_sib.add_column_if_missing
|
|
172
209
|
_run_pending_migrations = _cctally_db_sib._run_pending_migrations
|
|
@@ -502,20 +539,60 @@ def sync_cache(
|
|
|
502
539
|
# empty baseline.
|
|
503
540
|
conn.execute("DELETE FROM session_entries")
|
|
504
541
|
conn.execute("DELETE FROM session_files")
|
|
542
|
+
# Plan 1: conversation_messages shares the cost path's lifecycle.
|
|
543
|
+
# A rebuild re-derives the whole cache from on-disk JSONL, so the
|
|
544
|
+
# message index is wiped here (inside the held lock) and the
|
|
545
|
+
# per-file second seek-and-walk repopulates it. The FTS delete
|
|
546
|
+
# trigger empties conversation_fts row-by-row in lockstep.
|
|
547
|
+
conn.execute("DELETE FROM conversation_messages")
|
|
505
548
|
# Clear the walk-complete sentinel atomically with the wipe
|
|
506
549
|
# (cctally-dev#93, D5/D2): a stale "complete" marker must never
|
|
507
550
|
# survive a destructive rebuild. The end-of-loop write below
|
|
508
551
|
# re-establishes it only after this rebuild's clean walk.
|
|
509
552
|
conn.execute("DELETE FROM cache_meta WHERE key='claude_ingest_walk_complete'")
|
|
553
|
+
# Issue #139: a rebuild walks every file from offset 0, so the
|
|
554
|
+
# per-file second seek-and-walk below repopulates the whole message
|
|
555
|
+
# index — that satisfies any deferred existing-install backfill.
|
|
556
|
+
# Drop the pending flag here so the post-rebuild sync does not also
|
|
557
|
+
# run a redundant (idempotent but wasteful) offset-0 backfill pass.
|
|
558
|
+
conn.execute(
|
|
559
|
+
"DELETE FROM cache_meta WHERE key='conversation_backfill_pending'")
|
|
510
560
|
conn.commit()
|
|
511
561
|
eprint("[cache-sync] rebuild: cleared Claude cached entries")
|
|
512
562
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
563
|
+
# Issue #139: consume the deferred conversation_messages backfill. On an
|
|
564
|
+
# existing-install upgrade, cache migration 002 sets
|
|
565
|
+
# ``conversation_backfill_pending`` instead of walking the whole JSONL
|
|
566
|
+
# history inline (which stalled the triggering command — even a
|
|
567
|
+
# stats-only ``cctally report`` that fires the cache dispatcher but never
|
|
568
|
+
# reads cache.db). sync_cache is the natural owner: it already holds the
|
|
569
|
+
# flock + owns the walker, so a cache-consuming command or the
|
|
570
|
+
# background hook-tick absorbs the one-time offset-0 walk. The backfill
|
|
571
|
+
# touches ONLY conversation_messages (never the session_files cost
|
|
572
|
+
# cursor), is idempotent on (source_path, byte_offset), and commits
|
|
573
|
+
# per-file — so a crash leaves the flag set and the next sync re-runs
|
|
574
|
+
# cleanly. It writes + commits, so it must land here, BEFORE the
|
|
575
|
+
# zero-write-lock read+parse region below (and never on the rebuild
|
|
576
|
+
# path, which already cleared the flag and repopulates via the normal
|
|
577
|
+
# walk). A path-less/:memory: conn has no cache_meta only if the schema
|
|
578
|
+
# was never applied; the try/except tolerates that.
|
|
579
|
+
if not rebuild:
|
|
580
|
+
try:
|
|
581
|
+
_pending = conn.execute(
|
|
582
|
+
"SELECT 1 FROM cache_meta "
|
|
583
|
+
"WHERE key='conversation_backfill_pending'"
|
|
584
|
+
).fetchone() is not None
|
|
585
|
+
except sqlite3.OperationalError:
|
|
586
|
+
_pending = False
|
|
587
|
+
if _pending:
|
|
588
|
+
backfill_conversation_messages(conn)
|
|
589
|
+
conn.execute(
|
|
590
|
+
"DELETE FROM cache_meta "
|
|
591
|
+
"WHERE key='conversation_backfill_pending'"
|
|
592
|
+
)
|
|
593
|
+
conn.commit()
|
|
594
|
+
|
|
595
|
+
paths: list[pathlib.Path] = list(_iter_claude_jsonl_files())
|
|
519
596
|
stats.files_total = len(paths)
|
|
520
597
|
|
|
521
598
|
# This SELECT does NOT open an implicit transaction (Python's
|
|
@@ -614,6 +691,12 @@ def sync_cache(
|
|
|
614
691
|
f"dedup)"
|
|
615
692
|
)
|
|
616
693
|
conn.execute("DELETE FROM session_entries")
|
|
694
|
+
# Plan 1: truncation escalates to a full re-ingest of EVERY file,
|
|
695
|
+
# so conversation_messages is wiped here (parallel to the
|
|
696
|
+
# session_entries full-reset) and the per-file second seek-and-walk
|
|
697
|
+
# repopulates it from offset 0. Mirrors the cost path's lifecycle;
|
|
698
|
+
# the FTS delete trigger empties conversation_fts in lockstep.
|
|
699
|
+
conn.execute("DELETE FROM conversation_messages")
|
|
617
700
|
# Clear the walk-complete sentinel atomically with the truncation
|
|
618
701
|
# full-reset (cctally-dev#93, D5/D2): the cache is being wiped, so
|
|
619
702
|
# any "complete" marker is now stale. The end-of-loop write below
|
|
@@ -684,6 +767,7 @@ def sync_cache(
|
|
|
684
767
|
# Read + parse is a pure read; do it OUTSIDE the write transaction
|
|
685
768
|
# so a slow JSONL doesn't hold a SQLite lock.
|
|
686
769
|
rows: list[tuple[Any, ...]] = []
|
|
770
|
+
conv_rows: list[tuple[Any, ...]] = []
|
|
687
771
|
final_offset = start_offset
|
|
688
772
|
try:
|
|
689
773
|
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
@@ -713,7 +797,29 @@ def sync_cache(
|
|
|
713
797
|
json.dumps(extras, sort_keys=True) if extras else None,
|
|
714
798
|
entry.cost_usd,
|
|
715
799
|
))
|
|
800
|
+
# ``final_offset`` is the cost walk's stop. Capture it into a
|
|
801
|
+
# local int BEFORE the conversation walk below re-seeks the
|
|
802
|
+
# handle — the value is what session_files.last_byte_offset
|
|
803
|
+
# is written from, so it must reflect the COST walk's
|
|
804
|
+
# position, never the conversation walk's. (#Plan1 Task 4
|
|
805
|
+
# cursor-consistency invariant.)
|
|
716
806
|
final_offset = fh.tell()
|
|
807
|
+
# --- conversation message ingest (Plan 1) ----------------
|
|
808
|
+
# Second seek-and-walk over the SAME
|
|
809
|
+
# [start_offset, final_offset] byte region as the
|
|
810
|
+
# reconcile-guarded cost walk above, BEFORE the per-file
|
|
811
|
+
# cursor advances. Independent of the cost walk: it touches
|
|
812
|
+
# only conversation_messages, never session_entries or the
|
|
813
|
+
# cost-row build. We re-seek to start_offset and stop the
|
|
814
|
+
# moment a message row's byte_offset reaches the cost walk's
|
|
815
|
+
# final_offset, so the two walks always cover the identical
|
|
816
|
+
# span and a partial mid-write tail line (rewound by the
|
|
817
|
+
# parser) is left for the next sync.
|
|
818
|
+
fh.seek(start_offset)
|
|
819
|
+
for mrow in _iter_message_rows(fh, path_str):
|
|
820
|
+
if mrow.byte_offset >= final_offset:
|
|
821
|
+
break
|
|
822
|
+
conv_rows.append(_conv_row_tuple(mrow, path_str))
|
|
717
823
|
except OSError as exc:
|
|
718
824
|
eprint(f"[cache] could not read {jp}: {exc}")
|
|
719
825
|
walk_clean = False # skipped a file without ingesting (D5a)
|
|
@@ -793,6 +899,18 @@ def sync_cache(
|
|
|
793
899
|
rows,
|
|
794
900
|
)
|
|
795
901
|
stats.rows_changed += conn.total_changes - before
|
|
902
|
+
# Conversation message ingest (Plan 1). Lands in the SAME
|
|
903
|
+
# per-file write transaction as session_entries so the cost
|
|
904
|
+
# rows and message rows for a file commit atomically.
|
|
905
|
+
# INSERT OR IGNORE on UNIQUE(source_path, byte_offset): a
|
|
906
|
+
# resume-replayed line re-walked from a delta offset that
|
|
907
|
+
# already landed is a silent no-op, and the same physical line
|
|
908
|
+
# in two files (resume across JSONL) keeps BOTH rows. No
|
|
909
|
+
# per-file DELETE here — the only conversation_messages resets
|
|
910
|
+
# are the rebuild + truncation-escalation full-clears above
|
|
911
|
+
# (parallel to the cost path's lifecycle).
|
|
912
|
+
if conv_rows:
|
|
913
|
+
conn.executemany(_CONV_INSERT_SQL, conv_rows)
|
|
796
914
|
# UPSERT preserves session_id / project_path columns populated
|
|
797
915
|
# by _ensure_session_files_row at the top of this loop. A plain
|
|
798
916
|
# INSERT OR REPLACE would wipe them on every changed-file sync.
|
|
@@ -839,6 +957,12 @@ def sync_cache(
|
|
|
839
957
|
(dt.datetime.now(dt.timezone.utc).isoformat(),),
|
|
840
958
|
)
|
|
841
959
|
conn.commit()
|
|
960
|
+
# At-rest hardening (Plan 2, spec §5). Runs here — at the end of the
|
|
961
|
+
# write transaction, while the cache.db.lock flock is still held (so a
|
|
962
|
+
# concurrent writer can't be mid-checkpoint) AND after at least one
|
|
963
|
+
# write has materialized the -wal/-shm sidecars. open_cache_db hardens
|
|
964
|
+
# cache.db + the data dir; this finishes the job for the sidecars.
|
|
965
|
+
_harden_cache_sidecars()
|
|
842
966
|
return stats
|
|
843
967
|
finally:
|
|
844
968
|
try:
|
|
@@ -848,6 +972,56 @@ def sync_cache(
|
|
|
848
972
|
lock_fh.close()
|
|
849
973
|
|
|
850
974
|
|
|
975
|
+
def backfill_conversation_messages(conn: sqlite3.Connection) -> int:
|
|
976
|
+
"""One-time backfill of ``conversation_messages`` for existing installs
|
|
977
|
+
(Plan 1 Task 5). Walks EVERY Claude JSONL from offset 0 and inserts one
|
|
978
|
+
row per user/assistant line via ``_lib_conversation.iter_message_rows``.
|
|
979
|
+
|
|
980
|
+
Properties:
|
|
981
|
+
* Per-file commits — a short write transaction per JSONL file, never one
|
|
982
|
+
long transaction over the whole (potentially ~1M-line) history. The
|
|
983
|
+
backfill of a huge history can't hold the cache.db write lock for
|
|
984
|
+
minutes.
|
|
985
|
+
* Idempotent — ``INSERT OR IGNORE`` on ``UNIQUE(source_path,
|
|
986
|
+
byte_offset)``. A row already present (from a prior partial run or from
|
|
987
|
+
the live ``sync_cache`` ingest) is silently skipped.
|
|
988
|
+
* Crash-resumable — because each file commits independently and the
|
|
989
|
+
INSERT is idempotent, a re-run after a crash re-walks every file but
|
|
990
|
+
only the not-yet-committed rows actually land.
|
|
991
|
+
* Cursor-safe — touches ONLY ``conversation_messages``. It never reads or
|
|
992
|
+
writes ``session_files`` / ``session_entries``, so the cost delta
|
|
993
|
+
cursor is untouched: a later ``sync_cache`` still resumes the cost walk
|
|
994
|
+
from exactly where it left off.
|
|
995
|
+
|
|
996
|
+
Returns the number of rows inserted. Since issue #139 the caller is
|
|
997
|
+
``sync_cache`` itself (consuming the ``conversation_backfill_pending`` flag),
|
|
998
|
+
which already holds the ``cache.db.lock`` flock for the duration — the same
|
|
999
|
+
serialization cache migration 001 relies on. The 002 migration handler no
|
|
1000
|
+
longer walks inline; it only flags the work as pending.
|
|
1001
|
+
"""
|
|
1002
|
+
inserted = 0
|
|
1003
|
+
for jp in _iter_claude_jsonl_files():
|
|
1004
|
+
path_str = str(jp)
|
|
1005
|
+
rows: list[tuple[Any, ...]] = []
|
|
1006
|
+
try:
|
|
1007
|
+
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
1008
|
+
for m in _iter_message_rows(fh, path_str):
|
|
1009
|
+
rows.append(_conv_row_tuple(m, path_str))
|
|
1010
|
+
except OSError as exc:
|
|
1011
|
+
eprint(f"[conversation-backfill] could not read {jp}: {exc}")
|
|
1012
|
+
continue
|
|
1013
|
+
if rows:
|
|
1014
|
+
# cursor.rowcount after an executemany INSERT OR IGNORE is the
|
|
1015
|
+
# number of rows actually inserted (conflicts excluded), and —
|
|
1016
|
+
# unlike conn.total_changes — it is NOT inflated by the FTS
|
|
1017
|
+
# AFTER INSERT trigger's shadow-table writes.
|
|
1018
|
+
cur = conn.executemany(_CONV_INSERT_SQL, rows)
|
|
1019
|
+
conn.commit() # per-file commit — no long write txn
|
|
1020
|
+
if cur.rowcount and cur.rowcount > 0:
|
|
1021
|
+
inserted += cur.rowcount
|
|
1022
|
+
return inserted
|
|
1023
|
+
|
|
1024
|
+
|
|
851
1025
|
def iter_entries(
|
|
852
1026
|
conn: sqlite3.Connection,
|
|
853
1027
|
range_start: dt.datetime,
|
|
@@ -1561,17 +1735,27 @@ def _collect_codex_entries_direct(
|
|
|
1561
1735
|
def get_codex_entries(
|
|
1562
1736
|
range_start: dt.datetime,
|
|
1563
1737
|
range_end: dt.datetime,
|
|
1738
|
+
*,
|
|
1739
|
+
skip_sync: bool = False,
|
|
1564
1740
|
) -> list[CodexEntry]:
|
|
1565
1741
|
"""Cache-first Codex entry fetch with transparent fallback.
|
|
1566
1742
|
|
|
1567
1743
|
Every Codex-reading command must use this rather than touching
|
|
1568
1744
|
open_cache_db directly.
|
|
1745
|
+
|
|
1746
|
+
``skip_sync=True`` bypasses the ``sync_codex_cache`` ingest pass and serves
|
|
1747
|
+
whatever is already cached — for a second read in the same process whose
|
|
1748
|
+
range is a SUBSET of a range already fetched (the cache is already warm), so
|
|
1749
|
+
a redundant full JSONL walk is wasted work (mirrors ``get_entries``'
|
|
1750
|
+
``skip_sync``).
|
|
1569
1751
|
"""
|
|
1570
1752
|
try:
|
|
1571
1753
|
conn = open_cache_db()
|
|
1572
1754
|
except (sqlite3.DatabaseError, OSError) as exc:
|
|
1573
1755
|
eprint(f"[cache] unavailable ({exc}); falling back to direct JSONL parse")
|
|
1574
1756
|
return _collect_codex_entries_direct(range_start, range_end)
|
|
1757
|
+
if skip_sync:
|
|
1758
|
+
return iter_codex_entries(conn, range_start, range_end)
|
|
1575
1759
|
stats = sync_codex_cache(conn)
|
|
1576
1760
|
if stats.lock_contended:
|
|
1577
1761
|
# Sync commits file-by-file, so contention on the ingest lock
|
|
@@ -1590,6 +1774,60 @@ def get_codex_entries(
|
|
|
1590
1774
|
return iter_codex_entries(conn, range_start, range_end)
|
|
1591
1775
|
|
|
1592
1776
|
|
|
1777
|
+
def _sum_codex_cost_for_range(
|
|
1778
|
+
start: dt.datetime,
|
|
1779
|
+
end: dt.datetime,
|
|
1780
|
+
*,
|
|
1781
|
+
speed: str = "auto",
|
|
1782
|
+
skip_sync: bool = False,
|
|
1783
|
+
) -> float:
|
|
1784
|
+
"""Sum USD Codex cost of all `codex_session_entries` in ``[start, end)``.
|
|
1785
|
+
|
|
1786
|
+
The Codex analog of Claude's ``_sum_cost_for_range`` (bin/cctally), used by
|
|
1787
|
+
`cctally budget`'s Codex-vendor path (calendar-period + Codex budgets
|
|
1788
|
+
feature, spec §4). Reads the **cache DB** via ``get_codex_entries`` (which
|
|
1789
|
+
opens ``cache.db``, runs the Codex sync, and carries the contention /
|
|
1790
|
+
direct-parse fallback) — NEVER the budget's stats ``conn``, which has no
|
|
1791
|
+
Codex tables.
|
|
1792
|
+
|
|
1793
|
+
Spend is computed per entry via the SAME ``_calculate_codex_entry_cost``
|
|
1794
|
+
primitive the ``codex-*`` reports use (LiteLLM token semantics; unknown
|
|
1795
|
+
model → ``gpt-5`` fallback), so a Codex budget and ``codex-weekly`` agree to
|
|
1796
|
+
the cent. A lean sum — no per-entry sample collection (budgets don't need
|
|
1797
|
+
``_compute_codex_cost_stats``' samples list) — but routed through the same
|
|
1798
|
+
cost primitive so there is no second pricing copy.
|
|
1799
|
+
|
|
1800
|
+
``speed="auto"`` resolves to the SAME effective tier the ``codex-*`` reports
|
|
1801
|
+
use under the current config (``_resolve_codex_speed`` reads the active
|
|
1802
|
+
``$CODEX_HOME``/``config.toml`` — fast multiplies cost at calc time), so the
|
|
1803
|
+
figure matches what ``codex-weekly`` shows on this machine right now.
|
|
1804
|
+
|
|
1805
|
+
``get_codex_entries`` filters on ``timestamp_utc <= end``; the budget window
|
|
1806
|
+
is half-open ``[start, end)`` so an entry exactly at ``end`` is excluded
|
|
1807
|
+
here (mirrors the kernel's half-open elapsed math). Empty cache / no entries
|
|
1808
|
+
→ ``0.0``.
|
|
1809
|
+
|
|
1810
|
+
``skip_sync=True`` serves the already-warm cache without a fresh ingest —
|
|
1811
|
+
for a second sum in the same process over a sub-range of one already fetched
|
|
1812
|
+
(e.g. the recent-24h window after the full-period sum).
|
|
1813
|
+
"""
|
|
1814
|
+
c = _cctally()
|
|
1815
|
+
eff_speed = c._resolve_codex_speed(speed)
|
|
1816
|
+
total = 0.0
|
|
1817
|
+
for entry in c.get_codex_entries(start, end, skip_sync=skip_sync):
|
|
1818
|
+
if entry.timestamp >= end:
|
|
1819
|
+
continue
|
|
1820
|
+
total += c._calculate_codex_entry_cost(
|
|
1821
|
+
entry.model,
|
|
1822
|
+
entry.input_tokens,
|
|
1823
|
+
entry.cached_input_tokens,
|
|
1824
|
+
entry.output_tokens,
|
|
1825
|
+
entry.reasoning_output_tokens,
|
|
1826
|
+
speed=eff_speed,
|
|
1827
|
+
)
|
|
1828
|
+
return total
|
|
1829
|
+
|
|
1830
|
+
|
|
1593
1831
|
def get_entries(
|
|
1594
1832
|
range_start: dt.datetime,
|
|
1595
1833
|
range_end: dt.datetime,
|
|
@@ -1628,6 +1866,24 @@ def get_entries(
|
|
|
1628
1866
|
return iter_entries(conn, range_start, range_end, project=project)
|
|
1629
1867
|
|
|
1630
1868
|
|
|
1869
|
+
def _harden_cache_sidecars() -> None:
|
|
1870
|
+
"""Best-effort 0600 on cache.db + its -wal/-shm sidecars (Plan 2, spec §5).
|
|
1871
|
+
|
|
1872
|
+
The -wal/-shm sidecars are created on the first WRITE (not on connect), so
|
|
1873
|
+
this runs at the END of the sync_cache write transaction — under the held
|
|
1874
|
+
cache.db.lock flock, where they exist — NOT in open_cache_db (where the
|
|
1875
|
+
sidecars are absent → a silent no-op that would leave a 0644 WAL). All
|
|
1876
|
+
chmod is best-effort: swallow OSError, log, continue.
|
|
1877
|
+
"""
|
|
1878
|
+
base = str(_cctally_core.CACHE_DB_PATH)
|
|
1879
|
+
for path in (base, base + "-wal", base + "-shm"):
|
|
1880
|
+
try:
|
|
1881
|
+
if os.path.exists(path):
|
|
1882
|
+
os.chmod(path, 0o600)
|
|
1883
|
+
except OSError as exc:
|
|
1884
|
+
eprint(f"[cache] could not chmod {path} 0600 ({exc}); continuing")
|
|
1885
|
+
|
|
1886
|
+
|
|
1631
1887
|
# === Region 6: open_cache_db (was bin/cctally:9040-9155) ===
|
|
1632
1888
|
|
|
1633
1889
|
|
|
@@ -1640,6 +1896,14 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
1640
1896
|
"""
|
|
1641
1897
|
c = _cctally()
|
|
1642
1898
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1899
|
+
# cache.db holds plaintext conversation prose at rest (Plan 2, spec §5).
|
|
1900
|
+
# Harden the data dir to 0700 so the WAL window between connect and the
|
|
1901
|
+
# first write (which materializes the -wal/-shm sidecars, hardened in
|
|
1902
|
+
# sync_cache) is not world-readable. Best-effort: swallow OSError + continue.
|
|
1903
|
+
try:
|
|
1904
|
+
os.chmod(_cctally_core.APP_DIR, 0o700)
|
|
1905
|
+
except OSError as exc:
|
|
1906
|
+
eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
|
|
1643
1907
|
try:
|
|
1644
1908
|
conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
|
|
1645
1909
|
conn.execute("SELECT 1").fetchone()
|
|
@@ -1651,6 +1915,13 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
1651
1915
|
pass
|
|
1652
1916
|
conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
|
|
1653
1917
|
|
|
1918
|
+
# Best-effort 0600 on cache.db itself (the 0700 dir above backstops the
|
|
1919
|
+
# sidecars until the first write hardens them in sync_cache).
|
|
1920
|
+
try:
|
|
1921
|
+
os.chmod(_cctally_core.CACHE_DB_PATH, 0o600)
|
|
1922
|
+
except OSError as exc:
|
|
1923
|
+
eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
|
|
1924
|
+
|
|
1654
1925
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
1655
1926
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
1656
1927
|
|
|
@@ -1682,6 +1953,7 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
1682
1953
|
# §2.5, §3.3 + the @cache_migration decorator further down in this file.
|
|
1683
1954
|
_run_pending_migrations(
|
|
1684
1955
|
conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
|
|
1956
|
+
recover_version_ahead=True,
|
|
1685
1957
|
)
|
|
1686
1958
|
return conn
|
|
1687
1959
|
|