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 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
@@ -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)
@@ -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
- claude_dirs = _get_claude_data_dirs()
514
- paths: list[pathlib.Path] = []
515
- for claude_dir in claude_dirs:
516
- for jp in (claude_dir / "projects").glob("**/*.jsonl"):
517
- if jp.is_file():
518
- paths.append(jp)
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