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.
- package/CHANGELOG.md +9 -0
- package/bin/_cctally_cache.py +263 -6
- package/bin/_cctally_db.py +286 -1
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_query.py +950 -0
- package/bin/_lib_conversation_dispatch.py +547 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/package.json +5 -1
|
@@ -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.
|
|
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",
|