cctally 1.71.0 → 1.72.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.
@@ -0,0 +1,344 @@
1
+ """#313 P3: 180-day conversation-transcript retention prune kernel.
2
+
3
+ Prunes ONLY the re-derivable transcript rows:
4
+ * Claude: ``conversation_messages`` + their ``conversation_file_touches`` /
5
+ ``conversation_ai_titles`` / ``conversation_sessions`` browse-rollup rows.
6
+ * Codex: ``codex_conversation_events`` AND the #294 S6 normalized derived rows
7
+ those events feed — ``codex_conversation_messages``,
8
+ ``codex_conversation_file_touches``, and ``codex_conversation_rollups`` (plus
9
+ their FTS postings) — so a prune never strands orphaned browse/search state.
10
+
11
+ It NEVER touches cost/usage rows (``session_entries`` / ``codex_session_entries``),
12
+ the delta-resume cursors (``*_session_files``), or ``codex_conversation_threads``
13
+ (F5 — pruning threads disables ``source_analytics``'s whole range via
14
+ ``_require_joined_metadata``, since that range LEFT JOINs threads for cwd/git).
15
+
16
+ Eligibility is decided from the AUTHORITATIVE base tables, never the possibly
17
+ stale ``conversation_sessions`` rollup (F6): a group is prunable iff it has at
18
+ least one dated row and NO row at/after the cutoff. Rows whose timestamps are
19
+ entirely NULL are treated conservatively and never pruned in isolation (F12).
20
+ NULL identity (``session_id`` / ``conversation_key``) falls back to grouping by
21
+ ``source_path`` so malformed rows stay bounded rather than orphaned-unbounded
22
+ (F12).
23
+
24
+ The FTS5 indexes over ``conversation_messages`` (``conversation_fts``) and
25
+ ``conversation_ai_titles`` (``conversation_title_fts``) are external-content and
26
+ maintained by AFTER-DELETE triggers, which are logically correct on subset
27
+ deletes. Whole groups are deleted so those triggers keep the index consistent.
28
+ The kernel runs inside the caller's open transaction (the orchestrator owns the
29
+ flocks, ``BEGIN IMMEDIATE``, and commit).
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import datetime as dt
34
+ import fcntl
35
+ import sqlite3
36
+ from dataclasses import dataclass
37
+
38
+ import _cctally_core
39
+
40
+ UTC = dt.timezone.utc
41
+
42
+ # cache_meta throttle key (framework-untracked KV — NO schema migration, F7).
43
+ _RETENTION_LAST_PRUNE_KEY = "conversation_retention_last_prune_at"
44
+ _RETENTION_THROTTLE_SECONDS = 24 * 60 * 60
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PruneStats:
49
+ """Counts from one prune pass."""
50
+
51
+ claude_sessions: int = 0
52
+ claude_messages: int = 0
53
+ codex_conversations: int = 0
54
+ codex_events: int = 0
55
+
56
+ @property
57
+ def total_rows(self) -> int:
58
+ return self.claude_messages + self.codex_events
59
+
60
+
61
+ def _cutoff_iso(cutoff_utc: dt.datetime) -> str:
62
+ """Whole-second UTC ``...Z`` boundary for lex comparison against the stored
63
+ ``...Z`` timestamps.
64
+
65
+ Second-granular: the sub-second mixed-precision edge at the exact cutoff
66
+ second is immaterial for a 180-day window — any message wrongly classified
67
+ there is re-derivable from JSONL and the boundary self-corrects on the next
68
+ (daily) prune.
69
+ """
70
+ return cutoff_utc.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
71
+
72
+
73
+ def prune_conversation_transcripts(
74
+ conn: sqlite3.Connection, *, cutoff_utc: dt.datetime
75
+ ) -> PruneStats:
76
+ """Prune every transcript group whose latest activity is before ``cutoff_utc``."""
77
+ cutoff = _cutoff_iso(cutoff_utc)
78
+ claude_sessions, claude_messages = _prune_claude(conn, cutoff)
79
+ codex_conversations, codex_events = _prune_codex(conn, cutoff)
80
+ return PruneStats(
81
+ claude_sessions=claude_sessions,
82
+ claude_messages=claude_messages,
83
+ codex_conversations=codex_conversations,
84
+ codex_events=codex_events,
85
+ )
86
+
87
+
88
+ def _prunable_groups(
89
+ conn: sqlite3.Connection, table: str, key_col: str, cutoff: str
90
+ ) -> list[str]:
91
+ """Return group keys (``key_col`` values) with a dated row all before the
92
+ cutoff. MAX(timestamp_utc) IS NOT NULL excludes all-NULL-timestamp groups
93
+ (conservative — never pruned in isolation, F12). MAX(...) < cutoff is
94
+ equivalent to NOT EXISTS a row at/after the cutoff (NULLs are ignored by
95
+ MAX and never satisfy ``>= cutoff``)."""
96
+ sql = (
97
+ f"SELECT {key_col} FROM {table} "
98
+ f"WHERE {key_col} IS NOT NULL "
99
+ f"GROUP BY {key_col} "
100
+ f"HAVING MAX(timestamp_utc) IS NOT NULL AND MAX(timestamp_utc) < ?"
101
+ )
102
+ return [row[0] for row in conn.execute(sql, (cutoff,))]
103
+
104
+
105
+ def _prunable_null_identity_paths(
106
+ conn: sqlite3.Connection, table: str, key_col: str, cutoff: str
107
+ ) -> list[str]:
108
+ """F12: for rows with a NULL identity column, group by source_path so they
109
+ are pruned as a bounded unit rather than orphaned unbounded."""
110
+ sql = (
111
+ f"SELECT source_path FROM {table} "
112
+ f"WHERE {key_col} IS NULL "
113
+ f"GROUP BY source_path "
114
+ f"HAVING MAX(timestamp_utc) IS NOT NULL AND MAX(timestamp_utc) < ?"
115
+ )
116
+ return [row[0] for row in conn.execute(sql, (cutoff,))]
117
+
118
+
119
+ def _prune_claude(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
120
+ sessions = 0
121
+ messages = 0
122
+ for session_id in _prunable_groups(
123
+ conn, "conversation_messages", "session_id", cutoff
124
+ ):
125
+ conn.execute(
126
+ "DELETE FROM conversation_file_touches WHERE session_id = ?",
127
+ (session_id,),
128
+ )
129
+ cur = conn.execute(
130
+ "DELETE FROM conversation_messages WHERE session_id = ?",
131
+ (session_id,),
132
+ )
133
+ messages += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
134
+ conn.execute(
135
+ "DELETE FROM conversation_ai_titles WHERE session_id = ?",
136
+ (session_id,),
137
+ )
138
+ conn.execute(
139
+ "DELETE FROM conversation_sessions WHERE session_id = ?",
140
+ (session_id,),
141
+ )
142
+ sessions += 1
143
+ for source_path in _prunable_null_identity_paths(
144
+ conn, "conversation_messages", "session_id", cutoff
145
+ ):
146
+ # NULL-session messages carry no session_id-keyed titles/rollup and
147
+ # conversation_file_touches requires a NOT NULL session_id, so delete
148
+ # any stray touches by message_id, then the messages themselves.
149
+ ids = [
150
+ row[0]
151
+ for row in conn.execute(
152
+ "SELECT id FROM conversation_messages "
153
+ "WHERE session_id IS NULL AND source_path = ?",
154
+ (source_path,),
155
+ )
156
+ ]
157
+ if ids:
158
+ conn.executemany(
159
+ "DELETE FROM conversation_file_touches WHERE message_id = ?",
160
+ [(i,) for i in ids],
161
+ )
162
+ cur = conn.execute(
163
+ "DELETE FROM conversation_messages "
164
+ "WHERE session_id IS NULL AND source_path = ?",
165
+ (source_path,),
166
+ )
167
+ messages += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
168
+ sessions += 1
169
+ return sessions, messages
170
+
171
+
172
+ def _retention_due(conn: sqlite3.Connection, now_utc: dt.datetime) -> bool:
173
+ """True iff the prune has never run or last ran more than 24h ago."""
174
+ try:
175
+ row = conn.execute(
176
+ "SELECT value FROM cache_meta WHERE key=?",
177
+ (_RETENTION_LAST_PRUNE_KEY,),
178
+ ).fetchone()
179
+ except sqlite3.Error:
180
+ return True
181
+ if row is None or row[0] is None:
182
+ return True
183
+ try:
184
+ last = dt.datetime.fromisoformat(str(row[0]).replace("Z", "+00:00"))
185
+ except (TypeError, ValueError):
186
+ return True
187
+ if last.tzinfo is None or last.utcoffset() is None:
188
+ last = last.replace(tzinfo=UTC)
189
+ return (now_utc - last).total_seconds() >= _RETENTION_THROTTLE_SECONDS
190
+
191
+
192
+ def _stamp_retention_prune(conn: sqlite3.Connection, now_utc: dt.datetime) -> None:
193
+ conn.execute(
194
+ "INSERT INTO cache_meta(key, value) VALUES (?, ?) "
195
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
196
+ (_RETENTION_LAST_PRUNE_KEY, now_utc.astimezone(UTC).isoformat()),
197
+ )
198
+
199
+
200
+ def _maybe_prune_conversation_retention(
201
+ conn: sqlite3.Connection,
202
+ *,
203
+ now_utc: dt.datetime,
204
+ retention_days: int,
205
+ force: bool = False,
206
+ ) -> "PruneStats | None":
207
+ """Throttled, flock-serialized transcript retention prune (F7 + F9).
208
+
209
+ Returns the :class:`PruneStats` when a prune ran, or ``None`` when it was
210
+ skipped (retention disabled, throttled within 24h, or a lock contended).
211
+
212
+ Concurrency (F7): a dedicated non-blocking MAINTENANCE flock serializes prune
213
+ attempts across processes (a second dashboard skips cleanly). Under it, the
214
+ two provider flocks are taken in a FIXED order (Claude then Codex),
215
+ non-blocking, so a rebuild/reingest mid-flight makes the prune skip this
216
+ cycle rather than race between candidate selection and deletion. The prune of
217
+ both providers and the throttle stamp run in ONE ``BEGIN IMMEDIATE``
218
+ transaction, so the stamp is written only after both provider phases succeed;
219
+ any failure rolls back the whole thing (no stamp → retried next cycle).
220
+
221
+ ``conn`` must hold no provider flock and no open transaction (the caller
222
+ guarantees this — the dashboard opens a dedicated cache connection; the
223
+ from-zero-replay callers invoke it after releasing their sync flock, F9).
224
+ ``retention_days <= 0`` disables retention (keep forever).
225
+ """
226
+ if retention_days is None or retention_days <= 0:
227
+ return None
228
+ core = _cctally_core
229
+ try:
230
+ core.APP_DIR.mkdir(parents=True, exist_ok=True)
231
+ except OSError:
232
+ return None
233
+ maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
234
+ try:
235
+ try:
236
+ fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
237
+ except (BlockingIOError, OSError):
238
+ return None # another prune holds it; skip cleanly, do NOT stamp
239
+ if not force and not _retention_due(conn, now_utc):
240
+ return None
241
+ claude_fh = open(core.CACHE_LOCK_PATH, "w")
242
+ try:
243
+ try:
244
+ fcntl.flock(claude_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
245
+ except (BlockingIOError, OSError):
246
+ return None # a Claude sync is mid-flight; retry next cycle
247
+ codex_fh = open(core.CACHE_LOCK_CODEX_PATH, "w")
248
+ try:
249
+ try:
250
+ fcntl.flock(codex_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
251
+ except (BlockingIOError, OSError):
252
+ return None # a Codex sync is mid-flight; retry next cycle
253
+ cutoff = now_utc - dt.timedelta(days=int(retention_days))
254
+ conn.execute("BEGIN IMMEDIATE")
255
+ try:
256
+ stats = prune_conversation_transcripts(conn, cutoff_utc=cutoff)
257
+ _stamp_retention_prune(conn, now_utc)
258
+ conn.commit()
259
+ except Exception:
260
+ conn.rollback()
261
+ raise
262
+ return stats
263
+ finally:
264
+ try:
265
+ fcntl.flock(codex_fh, fcntl.LOCK_UN)
266
+ except OSError:
267
+ pass
268
+ codex_fh.close()
269
+ finally:
270
+ try:
271
+ fcntl.flock(claude_fh, fcntl.LOCK_UN)
272
+ except OSError:
273
+ pass
274
+ claude_fh.close()
275
+ finally:
276
+ try:
277
+ fcntl.flock(maint_fh, fcntl.LOCK_UN)
278
+ except OSError:
279
+ pass
280
+ maint_fh.close()
281
+
282
+
283
+ def _delete_codex_conversation_derived(
284
+ conn: sqlite3.Connection, conversation_key: str
285
+ ) -> None:
286
+ """#294 S6: drop one pruned conversation's normalized derived rows in the same
287
+ transaction as its physical events.
288
+
289
+ #313 prunes at WHOLE-conversation granularity — ``_prune_codex`` deletes every
290
+ ``codex_conversation_events`` row for the key, so every event across every file
291
+ of the conversation is gone. The S6 rollup ownership rule (§3.2) is therefore
292
+ the "or-delete" branch: with zero surviving normalized rows the rollup is
293
+ deleted outright, no recompute needed. The normalized-message DELETE is a
294
+ PARTIAL delete over the whole corpus (§3.4), so it rides the per-row FTS
295
+ AFTER-DELETE trigger — surviving conversations keep their postings — and NEVER
296
+ the full-clear ``'delete-all'``; when ``codex_fts_unavailable`` is set the
297
+ triggers are absent and these are plain base deletes. File touches carry an
298
+ explicit ``conversation_key`` so they scope exactly. Deleting the derived rows
299
+ is part of pruning the SAME conversation, so it does not change the reported
300
+ ``codex_conversations`` / ``codex_events`` counts (those stay physical).
301
+ """
302
+ conn.execute(
303
+ "DELETE FROM codex_conversation_file_touches WHERE conversation_key = ?",
304
+ (conversation_key,),
305
+ )
306
+ # Rides the codex_conv_fts_ad per-row trigger (partial delete, §3.4).
307
+ conn.execute(
308
+ "DELETE FROM codex_conversation_messages WHERE conversation_key = ?",
309
+ (conversation_key,),
310
+ )
311
+ conn.execute(
312
+ "DELETE FROM codex_conversation_rollups WHERE conversation_key = ?",
313
+ (conversation_key,),
314
+ )
315
+
316
+
317
+ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
318
+ conversations = 0
319
+ events = 0
320
+ for conversation_key in _prunable_groups(
321
+ conn, "codex_conversation_events", "conversation_key", cutoff
322
+ ):
323
+ # #294 S6: purge the derived normalized rows for this conversation in the
324
+ # SAME transaction as the events (§3.2 or-delete + §3.4 partial delete).
325
+ _delete_codex_conversation_derived(conn, conversation_key)
326
+ cur = conn.execute(
327
+ "DELETE FROM codex_conversation_events WHERE conversation_key = ?",
328
+ (conversation_key,),
329
+ )
330
+ events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
331
+ conversations += 1
332
+ for source_path in _prunable_null_identity_paths(
333
+ conn, "codex_conversation_events", "conversation_key", cutoff
334
+ ):
335
+ # NULL-conversation_key events never gained thread identity, so S6 never
336
+ # normalized them (§4.1) — there are no derived rows to clean up here.
337
+ cur = conn.execute(
338
+ "DELETE FROM codex_conversation_events "
339
+ "WHERE conversation_key IS NULL AND source_path = ?",
340
+ (source_path,),
341
+ )
342
+ events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
343
+ conversations += 1
344
+ return conversations, events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.71.0",
3
+ "version": "1.72.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {
@@ -61,11 +61,15 @@
61
61
  "bin/_lib_budget.py",
62
62
  "bin/_lib_cache_report.py",
63
63
  "bin/_lib_changelog.py",
64
+ "bin/_lib_codex_conversation.py",
65
+ "bin/_lib_codex_conversation_query.py",
64
66
  "bin/_lib_codex_hooks.py",
65
67
  "bin/_lib_conversation.py",
66
68
  "bin/_lib_conversation_anon.py",
69
+ "bin/_lib_conversation_dispatch.py",
67
70
  "bin/_lib_conversation_export.py",
68
71
  "bin/_lib_conversation_query.py",
72
+ "bin/_lib_conversation_retention.py",
69
73
  "bin/_lib_conversation_watch.py",
70
74
  "bin/_lib_credit.py",
71
75
  "bin/_lib_dashboard_dates.py",