claude-sql 0.5.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (33) hide show
  1. {claude_sql-0.5.0 → claude_sql-0.7.0}/PKG-INFO +4 -2
  2. {claude_sql-0.5.0 → claude_sql-0.7.0}/README.md +1 -0
  3. {claude_sql-0.5.0 → claude_sql-0.7.0}/pyproject.toml +4 -2
  4. claude_sql-0.7.0/src/claude_sql/checkpointer.py +376 -0
  5. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/cli.py +360 -124
  6. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/cluster_worker.py +28 -21
  7. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/community_worker.py +24 -19
  8. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/config.py +25 -24
  9. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/embed_worker.py +45 -42
  10. claude_sql-0.7.0/src/claude_sql/lance_store.py +261 -0
  11. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/output.py +10 -4
  12. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/retry_queue.py +76 -46
  13. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/sql_views.py +805 -490
  14. claude_sql-0.5.0/src/claude_sql/checkpointer.py +0 -202
  15. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/__init__.py +0 -0
  16. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/binding.py +0 -0
  17. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/blind_handover.py +0 -0
  18. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/freeze.py +0 -0
  19. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/friction_worker.py +0 -0
  20. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/install_source.py +0 -0
  21. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/judge_worker.py +0 -0
  22. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/judges.py +0 -0
  23. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/kappa_worker.py +0 -0
  24. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/llm_worker.py +0 -0
  25. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/logging_setup.py +0 -0
  26. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/parquet_shards.py +0 -0
  27. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/review_sheet_render.py +0 -0
  28. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/review_sheet_worker.py +0 -0
  29. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/schemas.py +0 -0
  30. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/session_text.py +0 -0
  31. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/skills_catalog.py +0 -0
  32. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/terms_worker.py +0 -0
  33. {claude_sql-0.5.0 → claude_sql-0.7.0}/src/claude_sql/ungrounded_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: claude-sql
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Zero-copy SQL + semantic search + LLM analytics over ~/.claude/ transcripts.
5
5
  Keywords: claude,claude-code,anthropic,duckdb,sql,semantic-search,embeddings,bedrock,transcripts,analytics,observability
6
6
  Author: Laith Al-Saadoon
@@ -21,9 +21,10 @@ Requires-Dist: anthropic>=0.40
21
21
  Requires-Dist: anyio>=4.13.0
22
22
  Requires-Dist: boto3>=1.42.91
23
23
  Requires-Dist: cyclopts>=4.10.2
24
- Requires-Dist: duckdb>=1.5.2
24
+ Requires-Dist: duckdb>=1.5.2,<2
25
25
  Requires-Dist: hdbscan>=0.8.40
26
26
  Requires-Dist: igraph>=1.0.0,<2.0
27
+ Requires-Dist: lancedb>=0.30,<0.31
27
28
  Requires-Dist: leidenalg>=0.11.0,<0.12
28
29
  Requires-Dist: loguru>=0.7.3
29
30
  Requires-Dist: numpy>=2.4.4
@@ -336,6 +337,7 @@ Commands that spend real Bedrock money default to `--dry-run`.
336
337
 
337
338
  | Macro | Signature | What it does |
338
339
  |---|---|---|
340
+ | `ago(interval_text)` | scalar → `TIMESTAMP` | `current_timestamp - INTERVAL <text>` -- e.g. `WHERE ts >= ago('30 days')` |
339
341
  | `model_used(sid)` | scalar → `VARCHAR` | Latest `model` observed in the session |
340
342
  | `cost_estimate(sid)` | scalar → `DOUBLE` | USD spend (dated model IDs prefix-matched) |
341
343
  | `tool_rank(last_n_days)` | table | Tool-use leaderboard over a window |
@@ -290,6 +290,7 @@ Commands that spend real Bedrock money default to `--dry-run`.
290
290
 
291
291
  | Macro | Signature | What it does |
292
292
  |---|---|---|
293
+ | `ago(interval_text)` | scalar → `TIMESTAMP` | `current_timestamp - INTERVAL <text>` -- e.g. `WHERE ts >= ago('30 days')` |
293
294
  | `model_used(sid)` | scalar → `VARCHAR` | Latest `model` observed in the session |
294
295
  | `cost_estimate(sid)` | scalar → `DOUBLE` | USD spend (dated model IDs prefix-matched) |
295
296
  | `tool_rank(last_n_days)` | table | Tool-use leaderboard over a window |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-sql"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "Zero-copy SQL + semantic search + LLM analytics over ~/.claude/ transcripts."
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -31,9 +31,11 @@ dependencies = [
31
31
  "anyio>=4.13.0",
32
32
  "boto3>=1.42.91",
33
33
  "cyclopts>=4.10.2",
34
- "duckdb>=1.5.2",
34
+ # 1.5.1 floor: lance core extension (used by embeddings store) became core. Upper-bounded to <2 to stay on the 1.x train.
35
+ "duckdb>=1.5.2,<2",
35
36
  "hdbscan>=0.8.40",
36
37
  "igraph>=1.0.0,<2.0",
38
+ "lancedb>=0.30,<0.31",
37
39
  "leidenalg>=0.11.0,<0.12",
38
40
  "loguru>=0.7.3",
39
41
  "numpy>=2.4.4",
@@ -0,0 +1,376 @@
1
+ """Per-(session_id, pipeline) checkpoint backed by a SQLite WAL file.
2
+
3
+ Tracks when each LLM pipeline last processed each session so re-runs skip
4
+ sessions whose transcripts have not advanced. One row per
5
+ ``(session_id, pipeline)``; ``INSERT ... ON CONFLICT DO UPDATE`` is the upsert
6
+ primitive (UPSERT, supported in SQLite 3.24+).
7
+
8
+ Schema::
9
+
10
+ CREATE TABLE session_checkpoint (
11
+ session_id TEXT NOT NULL,
12
+ pipeline TEXT NOT NULL,
13
+ last_ts_processed TEXT,
14
+ last_mtime_processed TEXT,
15
+ completed_at TEXT NOT NULL,
16
+ PRIMARY KEY (session_id, pipeline)
17
+ );
18
+
19
+ All timestamps are stored as ISO-8601 UTC strings (``2026-05-11T19:32:00.001Z``).
20
+ They sort lexicographically. We use the stdlib ``sqlite3`` module with WAL
21
+ journal mode so multiple readers can run concurrently with one writer — the
22
+ DuckDB single-writer file lock used to force a 20× retry storm under parallel
23
+ classify/trajectory/conflicts pipelines; SQLite's ``busy_timeout`` pragma
24
+ absorbs transient writer contention transparently in microseconds.
25
+
26
+ The file lives at ``~/.claude/state.db`` (overridable via
27
+ ``CLAUDE_SQL_CHECKPOINT_DB_PATH``). On first connect, if the legacy
28
+ ``~/.claude/claude_sql.duckdb`` exists, its contents are migrated once.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import sqlite3
34
+ import threading
35
+ from collections.abc import Iterable
36
+ from datetime import UTC, datetime
37
+ from pathlib import Path
38
+
39
+ from loguru import logger
40
+
41
+ PIPELINE_NAMES: tuple[str, ...] = ("classify", "trajectory", "conflicts", "user_friction")
42
+
43
+ _CREATE_TABLES_SQL = """
44
+ CREATE TABLE IF NOT EXISTS session_checkpoint (
45
+ session_id TEXT NOT NULL,
46
+ pipeline TEXT NOT NULL,
47
+ last_ts_processed TEXT,
48
+ last_mtime_processed TEXT,
49
+ completed_at TEXT NOT NULL,
50
+ PRIMARY KEY (session_id, pipeline)
51
+ );
52
+ """
53
+
54
+ _LEGACY_DUCKDB_FILENAME = "claude_sql.duckdb"
55
+ _MIGRATION_SENTINEL = ".migrated_from_duckdb"
56
+
57
+ # Process-local set of paths whose schema has already been bootstrapped this
58
+ # process. Skipping the redundant ``CREATE TABLE IF NOT EXISTS`` on every
59
+ # ``_connect`` call avoids racing the writer lock when concurrent threads
60
+ # open the same file. Guarded by a lock so the very first concurrent opens
61
+ # don't both win the "not bootstrapped yet" check and double-issue DDL.
62
+ _SCHEMA_BOOTSTRAPPED: set[str] = set()
63
+ _SCHEMA_BOOTSTRAP_LOCK = threading.Lock()
64
+
65
+
66
+ def _to_iso(dt: datetime | None) -> str | None:
67
+ """ISO-8601 UTC string, or None."""
68
+ if dt is None:
69
+ return None
70
+ return dt.astimezone(UTC).isoformat()
71
+
72
+
73
+ def _from_iso(s: str | None) -> datetime | None:
74
+ """Parse an ISO-8601 string back to a tz-aware UTC datetime."""
75
+ if s is None:
76
+ return None
77
+ dt = datetime.fromisoformat(s)
78
+ if dt.tzinfo is None:
79
+ dt = dt.replace(tzinfo=UTC)
80
+ return dt.astimezone(UTC)
81
+
82
+
83
+ def _legacy_duckdb_path(new_path: Path) -> Path:
84
+ return new_path.parent / _LEGACY_DUCKDB_FILENAME
85
+
86
+
87
+ def _migration_sentinel_path(new_path: Path) -> Path:
88
+ return new_path.parent / _MIGRATION_SENTINEL
89
+
90
+
91
+ def _migrate_from_duckdb_if_present(new_path: Path) -> None:
92
+ """One-time copy from legacy DuckDB file. Idempotent.
93
+
94
+ Reads ``session_checkpoint`` and ``retry_queue`` from the legacy DuckDB,
95
+ writes both into the SQLite file, drops a sentinel so we don't retry on
96
+ every open. Failures log and skip — never block the caller.
97
+ """
98
+ sentinel = _migration_sentinel_path(new_path)
99
+ if sentinel.exists():
100
+ return
101
+ legacy = _legacy_duckdb_path(new_path)
102
+ if not legacy.exists():
103
+ sentinel.parent.mkdir(parents=True, exist_ok=True)
104
+ sentinel.touch()
105
+ return
106
+ try:
107
+ import duckdb # local import — only needed for one-time migration
108
+ except ImportError:
109
+ # No duckdb at runtime — the codebase needs it elsewhere, but if the
110
+ # import ever fails here we still want SQLite to come up.
111
+ sentinel.touch()
112
+ return
113
+
114
+ checkpoint_rows: list[tuple] = []
115
+ retry_rows: list[tuple] = []
116
+ try:
117
+ old = duckdb.connect(str(legacy), read_only=True)
118
+ try:
119
+ try:
120
+ checkpoint_rows = [
121
+ (
122
+ str(sid),
123
+ str(pipeline),
124
+ _to_iso(last_ts) if isinstance(last_ts, datetime) else last_ts,
125
+ _to_iso(last_mtime) if isinstance(last_mtime, datetime) else last_mtime,
126
+ _to_iso(completed_at)
127
+ if isinstance(completed_at, datetime)
128
+ else completed_at,
129
+ )
130
+ for sid, pipeline, last_ts, last_mtime, completed_at in old.execute(
131
+ "SELECT session_id, pipeline, last_ts_processed, "
132
+ "last_mtime_processed, completed_at FROM session_checkpoint"
133
+ ).fetchall()
134
+ ]
135
+ except duckdb.CatalogException:
136
+ checkpoint_rows = []
137
+ try:
138
+ retry_rows = [
139
+ (
140
+ str(pipeline),
141
+ str(unit_id),
142
+ str(error),
143
+ int(attempts),
144
+ _to_iso(next_at) if isinstance(next_at, datetime) else next_at,
145
+ _to_iso(created_at) if isinstance(created_at, datetime) else created_at,
146
+ _to_iso(completed_at)
147
+ if isinstance(completed_at, datetime)
148
+ else completed_at,
149
+ )
150
+ for pipeline, unit_id, error, attempts, next_at, created_at, completed_at in old.execute(
151
+ "SELECT pipeline, unit_id, error, attempts, next_attempt_at, "
152
+ "created_at, completed_at FROM retry_queue"
153
+ ).fetchall()
154
+ ]
155
+ except duckdb.CatalogException:
156
+ retry_rows = []
157
+ finally:
158
+ old.close()
159
+ except Exception: # noqa: BLE001 — migration is best-effort; any failure must drop the sentinel and let SQLite come up clean
160
+ logger.exception("Failed to read legacy DuckDB at {} for migration", legacy)
161
+ sentinel.parent.mkdir(parents=True, exist_ok=True)
162
+ sentinel.touch()
163
+ return
164
+
165
+ new_path.parent.mkdir(parents=True, exist_ok=True)
166
+ # Open in autocommit so the PRAGMA setup runs outside a transaction
167
+ # (``PRAGMA journal_mode=WAL`` is a no-op inside one). After that, wrap
168
+ # the bulk INSERTs in a single explicit BEGIN/COMMIT — without it,
169
+ # autocommit treats every executemany row as its own transaction and
170
+ # fsyncs the WAL per row, turning a 39k-row migration into a many-minute
171
+ # hang. Single-transaction batch lands in <1s on the live corpus.
172
+ con = sqlite3.connect(str(new_path), isolation_level=None)
173
+ try:
174
+ con.execute("PRAGMA journal_mode=WAL")
175
+ con.execute("PRAGMA synchronous=NORMAL")
176
+ con.execute(_CREATE_TABLES_SQL)
177
+ con.execute(
178
+ """
179
+ CREATE TABLE IF NOT EXISTS retry_queue (
180
+ pipeline TEXT NOT NULL,
181
+ unit_id TEXT NOT NULL,
182
+ error TEXT NOT NULL,
183
+ attempts INTEGER NOT NULL DEFAULT 0,
184
+ next_attempt_at TEXT NOT NULL,
185
+ created_at TEXT NOT NULL,
186
+ completed_at TEXT,
187
+ PRIMARY KEY (pipeline, unit_id)
188
+ );
189
+ """
190
+ )
191
+ if checkpoint_rows or retry_rows:
192
+ con.execute("BEGIN")
193
+ try:
194
+ if checkpoint_rows:
195
+ con.executemany(
196
+ "INSERT OR REPLACE INTO session_checkpoint "
197
+ "(session_id, pipeline, last_ts_processed, last_mtime_processed, completed_at) "
198
+ "VALUES (?, ?, ?, ?, ?)",
199
+ checkpoint_rows,
200
+ )
201
+ if retry_rows:
202
+ con.executemany(
203
+ "INSERT OR REPLACE INTO retry_queue "
204
+ "(pipeline, unit_id, error, attempts, next_attempt_at, created_at, completed_at) "
205
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
206
+ retry_rows,
207
+ )
208
+ con.execute("COMMIT")
209
+ except Exception:
210
+ con.execute("ROLLBACK")
211
+ raise
212
+ finally:
213
+ con.close()
214
+ sentinel.touch()
215
+ logger.info(
216
+ "Migrated {} checkpoint rows + {} retry rows from {} to {}",
217
+ len(checkpoint_rows),
218
+ len(retry_rows),
219
+ legacy,
220
+ new_path,
221
+ )
222
+
223
+
224
+ def _connect(path: Path, *, max_attempts: int = 20) -> sqlite3.Connection:
225
+ """Open the SQLite checkpoint DB and ensure tables exist.
226
+
227
+ SQLite WAL mode allows many concurrent readers + one writer. Transient
228
+ write contention is absorbed by ``PRAGMA busy_timeout=5000`` (5s wait),
229
+ so the 20× exponential-backoff retry loop the DuckDB version needed is
230
+ gone. ``max_attempts`` is kept as a no-op kwarg for back-compat.
231
+ """
232
+ path.parent.mkdir(parents=True, exist_ok=True)
233
+ _migrate_from_duckdb_if_present(path)
234
+ # Connect in autocommit (isolation_level=None) so the per-connection
235
+ # ``busy_timeout`` PRAGMA runs outside a transaction. Switch to deferred
236
+ # mode at the end so writes wrap in implicit BEGIN/COMMIT.
237
+ con = sqlite3.connect(str(path), isolation_level=None, timeout=30.0)
238
+ # Per-connection PRAGMAs (must run every open).
239
+ con.execute("PRAGMA busy_timeout=10000")
240
+ con.execute("PRAGMA foreign_keys=ON")
241
+ # File-level setup runs exactly once per (process, path) under a lock.
242
+ # ``PRAGMA journal_mode=WAL`` and ``synchronous=NORMAL`` persist in the
243
+ # DB header; running them on every open races concurrent cold-start
244
+ # writers (the WAL transition acquires the writer lock and the loser
245
+ # raises ``OperationalError: database is locked``). ``CREATE TABLE IF
246
+ # NOT EXISTS`` has the same race even though it is conceptually
247
+ # idempotent — the DDL still grabs the writer lock under contention.
248
+ # Caching the bootstrap behind ``_SCHEMA_BOOTSTRAPPED`` removes both.
249
+ key = str(path.resolve())
250
+ with _SCHEMA_BOOTSTRAP_LOCK:
251
+ if key not in _SCHEMA_BOOTSTRAPPED:
252
+ con.execute("PRAGMA journal_mode=WAL")
253
+ con.execute("PRAGMA synchronous=NORMAL")
254
+ con.execute(_CREATE_TABLES_SQL)
255
+ _SCHEMA_BOOTSTRAPPED.add(key)
256
+ con.isolation_level = (
257
+ "DEFERRED" # writes wrap in implicit BEGIN/COMMIT; lock contention waits on timeout
258
+ )
259
+ return con
260
+
261
+
262
+ def load_as_map(db_path: Path, pipeline: str) -> dict[str, tuple[datetime | None, datetime | None]]:
263
+ """Return ``{session_id: (last_ts, last_mtime)}`` for one pipeline.
264
+
265
+ Empty dict when the DB doesn't exist yet or the pipeline has no rows.
266
+ """
267
+ if not db_path.exists() and not _legacy_duckdb_path(db_path).exists():
268
+ return {}
269
+ con = _connect(db_path)
270
+ try:
271
+ rows = con.execute(
272
+ "SELECT session_id, last_ts_processed, last_mtime_processed "
273
+ "FROM session_checkpoint WHERE pipeline = ?",
274
+ [pipeline],
275
+ ).fetchall()
276
+ finally:
277
+ con.close()
278
+ return {
279
+ str(sid): (_from_iso(last_ts), _from_iso(last_mtime)) for sid, last_ts, last_mtime in rows
280
+ }
281
+
282
+
283
+ def filter_unchanged(
284
+ candidates: Iterable[tuple[str, datetime | None, datetime | None]],
285
+ *,
286
+ pipeline: str,
287
+ checkpoint_db_path: Path,
288
+ ) -> tuple[list[str], int]:
289
+ """Drop sessions whose ``(last_ts, last_mtime)`` has not advanced.
290
+
291
+ ``candidates`` is an iterable of ``(session_id, current_last_ts,
292
+ current_last_mtime)``. Returns ``(pending_session_ids, skipped_count)``.
293
+
294
+ A session is skipped iff a checkpoint row exists for ``pipeline`` AND
295
+ both ``current_last_ts <= ckpt.last_ts`` AND ``current_last_mtime <=
296
+ ckpt.last_mtime``. Either bound moving forward invalidates the skip.
297
+ """
298
+ ckpt = load_as_map(checkpoint_db_path, pipeline)
299
+ pending: list[str] = []
300
+ skipped = 0
301
+ for sid, cur_ts, cur_mtime in candidates:
302
+ prev = ckpt.get(sid)
303
+ if prev is None:
304
+ pending.append(sid)
305
+ continue
306
+ prev_ts, prev_mtime = prev
307
+ if _stale_or_equal(cur_ts, prev_ts) and _stale_or_equal(cur_mtime, prev_mtime):
308
+ skipped += 1
309
+ continue
310
+ pending.append(sid)
311
+ return pending, skipped
312
+
313
+
314
+ def _stale_or_equal(cur: datetime | None, prev: datetime | None) -> bool:
315
+ """True iff both are present and ``cur`` has not advanced past ``prev``.
316
+
317
+ Both inputs are tz-aware UTC datetimes after the boundary helpers have
318
+ run; we compare directly. None on either side returns False (advance).
319
+ """
320
+ if cur is None or prev is None:
321
+ return False
322
+ cur_aware = cur.astimezone(UTC) if cur.tzinfo else cur.replace(tzinfo=UTC)
323
+ prev_aware = prev.astimezone(UTC) if prev.tzinfo else prev.replace(tzinfo=UTC)
324
+ return cur_aware <= prev_aware
325
+
326
+
327
+ def mark_completed(
328
+ db_path: Path,
329
+ *,
330
+ pipeline: str,
331
+ rows: Iterable[tuple[str, datetime | None, datetime | None]],
332
+ ) -> int:
333
+ """Upsert checkpoint rows for ``(session_id, pipeline)``.
334
+
335
+ Each row is ``(session_id, last_ts_processed, last_mtime_processed)``.
336
+ The ``completed_at`` column is stamped with ``datetime.now(UTC)``.
337
+
338
+ Returns the number of upserted rows. When ``rows`` is empty, the DB is
339
+ left untouched.
340
+ """
341
+ incoming = list(rows)
342
+ if not incoming:
343
+ return 0
344
+ now_iso = _to_iso(datetime.now(UTC))
345
+ payload = [
346
+ (sid, pipeline, _to_iso(last_ts), _to_iso(last_mtime), now_iso)
347
+ for sid, last_ts, last_mtime in incoming
348
+ ]
349
+ con = _connect(db_path)
350
+ try:
351
+ con.executemany(
352
+ "INSERT INTO session_checkpoint "
353
+ "(session_id, pipeline, last_ts_processed, last_mtime_processed, completed_at) "
354
+ "VALUES (?, ?, ?, ?, ?) "
355
+ "ON CONFLICT(session_id, pipeline) DO UPDATE SET "
356
+ "last_ts_processed = excluded.last_ts_processed, "
357
+ "last_mtime_processed = excluded.last_mtime_processed, "
358
+ "completed_at = excluded.completed_at",
359
+ payload,
360
+ )
361
+ con.commit()
362
+ finally:
363
+ con.close()
364
+ return len(incoming)
365
+
366
+
367
+ def count_rows(db_path: Path) -> int:
368
+ """Return the total number of checkpoint rows, or 0 when the DB is missing."""
369
+ if not db_path.exists() and not _legacy_duckdb_path(db_path).exists():
370
+ return 0
371
+ con = _connect(db_path)
372
+ try:
373
+ row = con.execute("SELECT count(*) FROM session_checkpoint").fetchone()
374
+ finally:
375
+ con.close()
376
+ return int(row[0]) if row else 0