cursor-chat-cleaner 0.1.0__py3-none-any.whl
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.
- cursor_chat_cleaner/__init__.py +1 -0
- cursor_chat_cleaner/__main__.py +4 -0
- cursor_chat_cleaner/cli.py +500 -0
- cursor_chat_cleaner/schema.py +120 -0
- cursor_chat_cleaner/store.py +1295 -0
- cursor_chat_cleaner/transcripts.py +118 -0
- cursor_chat_cleaner-0.1.0.dist-info/METADATA +197 -0
- cursor_chat_cleaner-0.1.0.dist-info/RECORD +12 -0
- cursor_chat_cleaner-0.1.0.dist-info/WHEEL +5 -0
- cursor_chat_cleaner-0.1.0.dist-info/entry_points.txt +2 -0
- cursor_chat_cleaner-0.1.0.dist-info/licenses/LICENSE +21 -0
- cursor_chat_cleaner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cursor_chat_cleaner import __version__
|
|
11
|
+
from cursor_chat_cleaner.store import (
|
|
12
|
+
SORT_KEYS,
|
|
13
|
+
CursorPaths,
|
|
14
|
+
aggregate_stats,
|
|
15
|
+
backup_chats,
|
|
16
|
+
cleanup_chat_artifacts,
|
|
17
|
+
cursor_is_running,
|
|
18
|
+
default_backup_dir,
|
|
19
|
+
delete_chats,
|
|
20
|
+
format_bytes,
|
|
21
|
+
format_conversation,
|
|
22
|
+
format_stats,
|
|
23
|
+
list_chats,
|
|
24
|
+
load_conversation,
|
|
25
|
+
summarize_repos,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_CLI_ERRORS = (FileNotFoundError, RuntimeError, ValueError, sqlite3.Error, OSError)
|
|
29
|
+
|
|
30
|
+
GC_BLOBS_HINT = (
|
|
31
|
+
"Deleted chats leave orphaned hash-keyed rows in state.vscdb "
|
|
32
|
+
"(agentKv:blob, composer.content.*, and inlineDiff). This tool cannot "
|
|
33
|
+
"safely remove them. After Cursor is quit, run Developer: GC Agent KV "
|
|
34
|
+
"Blobs from the Command Palette (Cmd+Shift+P) to reclaim unreferenced "
|
|
35
|
+
"agentKv blobs. It does not remove chats. composer.content and "
|
|
36
|
+
"inlineDiff rows may still remain."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="cursor-chat-cleaner",
|
|
43
|
+
description="Find, back up, and delete Cursor chats on this Mac.",
|
|
44
|
+
epilog=(
|
|
45
|
+
"Quit Cursor before deleting. After a large cleanup, run "
|
|
46
|
+
"'Developer: GC Agent KV Blobs' from the Command Palette to reclaim "
|
|
47
|
+
"orphaned blob space."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--user-dir",
|
|
53
|
+
type=Path,
|
|
54
|
+
help=(
|
|
55
|
+
"Cursor User directory (default: ~/Library/Application Support/Cursor/User). "
|
|
56
|
+
"For Nightly: ~/Library/Application Support/Cursor Nightly/User"
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--projects-dir",
|
|
61
|
+
type=Path,
|
|
62
|
+
help="Cursor projects directory (default: ~/.cursor/projects)",
|
|
63
|
+
)
|
|
64
|
+
sub = parser.add_subparsers(dest="command")
|
|
65
|
+
|
|
66
|
+
list_p = sub.add_parser("list", help="Show chats (archived only by default)")
|
|
67
|
+
_add_filters(list_p)
|
|
68
|
+
list_p.add_argument(
|
|
69
|
+
"--all",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Include active chats, not only archived",
|
|
72
|
+
)
|
|
73
|
+
list_p.add_argument(
|
|
74
|
+
"--sort",
|
|
75
|
+
choices=SORT_KEYS,
|
|
76
|
+
default="updated",
|
|
77
|
+
help="Sort by this field (default: updated, newest first)",
|
|
78
|
+
)
|
|
79
|
+
list_p.add_argument(
|
|
80
|
+
"--reverse",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Flip the default sort direction",
|
|
83
|
+
)
|
|
84
|
+
list_p.add_argument("--json", action="store_true", help="Print JSON")
|
|
85
|
+
list_p.add_argument(
|
|
86
|
+
"--repos",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="List unique repos instead of chats",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
backup_p = sub.add_parser(
|
|
92
|
+
"backup",
|
|
93
|
+
help="Copy selected chats (archived only unless --id, --repo, or --all)",
|
|
94
|
+
)
|
|
95
|
+
_add_filters(backup_p)
|
|
96
|
+
backup_p.add_argument(
|
|
97
|
+
"--all",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Include active chats, not only archived",
|
|
100
|
+
)
|
|
101
|
+
backup_p.add_argument(
|
|
102
|
+
"--dest",
|
|
103
|
+
type=Path,
|
|
104
|
+
help="Backup directory (default: ~/cursor-chat-cleaner-backups/<timestamp>)",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
delete_p = sub.add_parser(
|
|
108
|
+
"delete",
|
|
109
|
+
help="Delete chats (archived only unless --id or --repo)",
|
|
110
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
111
|
+
epilog=GC_BLOBS_HINT,
|
|
112
|
+
)
|
|
113
|
+
_add_filters(delete_p)
|
|
114
|
+
delete_p.add_argument(
|
|
115
|
+
"--dry-run",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Show what would be deleted without changing anything",
|
|
118
|
+
)
|
|
119
|
+
delete_p.add_argument(
|
|
120
|
+
"--yes",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="Actually delete (refused unless Cursor is quit, or --force)",
|
|
123
|
+
)
|
|
124
|
+
delete_p.add_argument(
|
|
125
|
+
"--force",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="Delete even if Cursor is still running (history may come back)",
|
|
128
|
+
)
|
|
129
|
+
delete_p.add_argument(
|
|
130
|
+
"--backup",
|
|
131
|
+
nargs="?",
|
|
132
|
+
const="__default__",
|
|
133
|
+
metavar="DIR",
|
|
134
|
+
help="Back up selected chats before deleting (default: ~/cursor-chat-cleaner-backups/<timestamp>)",
|
|
135
|
+
)
|
|
136
|
+
delete_p.add_argument(
|
|
137
|
+
"--vacuum",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="VACUUM the database after delete to reclaim file space",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
stats_p = sub.add_parser("stats", help="Show model, line, and token totals")
|
|
143
|
+
_add_filters(stats_p)
|
|
144
|
+
stats_p.add_argument(
|
|
145
|
+
"--archived",
|
|
146
|
+
action="store_true",
|
|
147
|
+
help="Archived chats only (default: all chats)",
|
|
148
|
+
)
|
|
149
|
+
stats_p.add_argument("--json", action="store_true", help="Print JSON")
|
|
150
|
+
|
|
151
|
+
view_p = sub.add_parser("view", help="Print one chat")
|
|
152
|
+
view_p.add_argument("composer_id", metavar="ID", help="Composer id from list")
|
|
153
|
+
view_p.add_argument("--json", action="store_true", help="Print JSON")
|
|
154
|
+
view_p.add_argument(
|
|
155
|
+
"--thinking",
|
|
156
|
+
action="store_true",
|
|
157
|
+
help="Include thinking bubbles",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
cleanup_p = sub.add_parser(
|
|
161
|
+
"cleanup",
|
|
162
|
+
help="Retry search-index and transcript cleanup by chat id",
|
|
163
|
+
)
|
|
164
|
+
cleanup_p.add_argument(
|
|
165
|
+
"--id",
|
|
166
|
+
action="append",
|
|
167
|
+
dest="ids",
|
|
168
|
+
required=True,
|
|
169
|
+
metavar="COMPOSER_ID",
|
|
170
|
+
help="Chat id left by a partial delete (repeatable)",
|
|
171
|
+
)
|
|
172
|
+
cleanup_p.add_argument(
|
|
173
|
+
"--yes",
|
|
174
|
+
action="store_true",
|
|
175
|
+
help="Actually remove matching search rows and transcript directories",
|
|
176
|
+
)
|
|
177
|
+
cleanup_p.add_argument(
|
|
178
|
+
"--force",
|
|
179
|
+
action="store_true",
|
|
180
|
+
help="Clean up even if Cursor is still running",
|
|
181
|
+
)
|
|
182
|
+
cleanup_p.add_argument(
|
|
183
|
+
"--vacuum",
|
|
184
|
+
action="store_true",
|
|
185
|
+
help="VACUUM the main database after cleanup",
|
|
186
|
+
)
|
|
187
|
+
return parser
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _add_filters(parser: argparse.ArgumentParser) -> None:
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"--workspace",
|
|
193
|
+
help="Only chats whose workspace path, id, or repo contains this text",
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--repo",
|
|
197
|
+
help="Only chats whose repo name matches exactly (folder name)",
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--older-than",
|
|
201
|
+
type=_positive_days,
|
|
202
|
+
metavar="DAYS",
|
|
203
|
+
help="Only chats last updated more than DAYS ago (minimum 1)",
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--id",
|
|
207
|
+
action="append",
|
|
208
|
+
dest="ids",
|
|
209
|
+
metavar="COMPOSER_ID",
|
|
210
|
+
help="This chat id, archived or not (repeatable)",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _positive_days(value: str) -> int:
|
|
215
|
+
try:
|
|
216
|
+
days = int(value)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise argparse.ArgumentTypeError("DAYS must be an integer") from exc
|
|
219
|
+
if days < 1:
|
|
220
|
+
raise argparse.ArgumentTypeError("DAYS must be at least 1")
|
|
221
|
+
return days
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def paths_from_args(args) -> CursorPaths:
|
|
225
|
+
kwargs: dict[str, Path] = {}
|
|
226
|
+
if getattr(args, "user_dir", None) is not None:
|
|
227
|
+
kwargs["user_dir"] = args.user_dir.expanduser()
|
|
228
|
+
if getattr(args, "projects_dir", None) is not None:
|
|
229
|
+
kwargs["projects_dir"] = args.projects_dir.expanduser()
|
|
230
|
+
return CursorPaths(**kwargs)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def selection_archived_only(command: str, args) -> bool:
|
|
234
|
+
if command == "stats":
|
|
235
|
+
return bool(getattr(args, "archived", False))
|
|
236
|
+
if getattr(args, "ids", None) or getattr(args, "all", False):
|
|
237
|
+
return False
|
|
238
|
+
if command in {"delete", "backup"} and getattr(args, "repo", None):
|
|
239
|
+
return False
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def active_delete_warning(chats) -> str | None:
|
|
244
|
+
active = sum(1 for chat in chats if not chat.is_archived)
|
|
245
|
+
if not active:
|
|
246
|
+
return None
|
|
247
|
+
return (
|
|
248
|
+
f"{active} active chat(s) in this selection will be deleted, "
|
|
249
|
+
"not only archived."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
254
|
+
parser = build_parser()
|
|
255
|
+
args = parser.parse_args(argv)
|
|
256
|
+
command = args.command or "list"
|
|
257
|
+
paths = paths_from_args(args)
|
|
258
|
+
if command == "view":
|
|
259
|
+
return _cmd_view(args, paths)
|
|
260
|
+
if command == "cleanup":
|
|
261
|
+
return _cmd_cleanup(args, paths)
|
|
262
|
+
ids = getattr(args, "ids", None)
|
|
263
|
+
repo = getattr(args, "repo", None)
|
|
264
|
+
archived_only = selection_archived_only(command, args)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
chats = list_chats(
|
|
268
|
+
paths,
|
|
269
|
+
workspace=getattr(args, "workspace", None),
|
|
270
|
+
repo=repo,
|
|
271
|
+
older_than_days=getattr(args, "older_than", None),
|
|
272
|
+
ids=ids,
|
|
273
|
+
archived_only=archived_only,
|
|
274
|
+
sort=getattr(args, "sort", "updated"),
|
|
275
|
+
reverse=getattr(args, "reverse", False),
|
|
276
|
+
sizes=command != "stats" and not getattr(args, "repos", False),
|
|
277
|
+
)
|
|
278
|
+
except _CLI_ERRORS as exc:
|
|
279
|
+
print(exc, file=sys.stderr)
|
|
280
|
+
return 2
|
|
281
|
+
|
|
282
|
+
if ids:
|
|
283
|
+
found = {chat.composer_id for chat in chats}
|
|
284
|
+
missing = [chat_id for chat_id in ids if chat_id not in found]
|
|
285
|
+
if missing:
|
|
286
|
+
print("Chat not found: " + ", ".join(missing), file=sys.stderr)
|
|
287
|
+
return 2
|
|
288
|
+
|
|
289
|
+
if command == "list":
|
|
290
|
+
if getattr(args, "repos", False):
|
|
291
|
+
_print_repos(chats, as_json=getattr(args, "json", False))
|
|
292
|
+
elif getattr(args, "json", False):
|
|
293
|
+
print(json.dumps([_chat_json(chat) for chat in chats], indent=2))
|
|
294
|
+
else:
|
|
295
|
+
_print_table(chats)
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
if command == "stats":
|
|
299
|
+
stats = aggregate_stats(chats)
|
|
300
|
+
if getattr(args, "json", False):
|
|
301
|
+
print(
|
|
302
|
+
json.dumps(
|
|
303
|
+
{
|
|
304
|
+
"chats": stats.chats,
|
|
305
|
+
"archived": stats.archived,
|
|
306
|
+
"lines_added": stats.lines_added,
|
|
307
|
+
"lines_removed": stats.lines_removed,
|
|
308
|
+
"files_changed": stats.files_changed,
|
|
309
|
+
"tokens": stats.tokens,
|
|
310
|
+
"models": stats.models,
|
|
311
|
+
"repos": stats.repos,
|
|
312
|
+
},
|
|
313
|
+
indent=2,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
print(format_stats(stats), end="")
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
if not chats:
|
|
321
|
+
print("No chats matched.")
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
_print_table(chats)
|
|
325
|
+
|
|
326
|
+
if command == "backup":
|
|
327
|
+
dest = args.dest if args.dest is not None else default_backup_dir()
|
|
328
|
+
try:
|
|
329
|
+
result = backup_chats(paths, chats, dest)
|
|
330
|
+
except _CLI_ERRORS as exc:
|
|
331
|
+
print(exc, file=sys.stderr)
|
|
332
|
+
return 2
|
|
333
|
+
print(f"Backed up {result.chats} chat(s) to {result.path}")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
warning = active_delete_warning(chats)
|
|
337
|
+
if warning:
|
|
338
|
+
print(warning)
|
|
339
|
+
|
|
340
|
+
if args.dry_run or not args.yes:
|
|
341
|
+
print("Dry run. Re-run with --yes to delete. Quit Cursor first.")
|
|
342
|
+
if args.backup:
|
|
343
|
+
print("A backup would be written first.")
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
if cursor_is_running() and not args.force:
|
|
347
|
+
print(
|
|
348
|
+
"Cursor is running. Quit it fully, then re-run, or pass --force.",
|
|
349
|
+
file=sys.stderr,
|
|
350
|
+
)
|
|
351
|
+
return 3
|
|
352
|
+
|
|
353
|
+
backup_dir = None
|
|
354
|
+
if args.backup:
|
|
355
|
+
backup_dir = default_backup_dir() if args.backup == "__default__" else Path(args.backup)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
result = delete_chats(
|
|
359
|
+
paths,
|
|
360
|
+
chats,
|
|
361
|
+
vacuum=args.vacuum,
|
|
362
|
+
backup_dir=backup_dir,
|
|
363
|
+
require_cursor_stopped=not args.force,
|
|
364
|
+
)
|
|
365
|
+
except _CLI_ERRORS as exc:
|
|
366
|
+
print(exc, file=sys.stderr)
|
|
367
|
+
return 2
|
|
368
|
+
print(
|
|
369
|
+
f"Deleted {result.chats} chat(s): "
|
|
370
|
+
f"{result.header_rows} headers, {result.kv_rows} kv rows, "
|
|
371
|
+
f"{result.search_rows} search rows, "
|
|
372
|
+
f"{result.transcript_dirs} transcript directories, "
|
|
373
|
+
f"{result.transcript_files} nested transcript files "
|
|
374
|
+
f"({format_bytes(result.bytes_removed)})."
|
|
375
|
+
)
|
|
376
|
+
if result.backup_path:
|
|
377
|
+
print(f"Backup: {result.backup_path}")
|
|
378
|
+
if result.vacuumed:
|
|
379
|
+
print("Database vacuumed.")
|
|
380
|
+
print(GC_BLOBS_HINT)
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _cmd_cleanup(args, paths: CursorPaths) -> int:
|
|
385
|
+
ids = list(dict.fromkeys(args.ids))
|
|
386
|
+
print("Cleanup IDs:")
|
|
387
|
+
for composer_id in ids:
|
|
388
|
+
print(f" {composer_id}")
|
|
389
|
+
if not args.yes:
|
|
390
|
+
print("Dry run. Re-run with --yes to remove leftover search rows and transcripts.")
|
|
391
|
+
return 0
|
|
392
|
+
if cursor_is_running() and not args.force:
|
|
393
|
+
print(
|
|
394
|
+
"Cursor is running. Quit it fully, then re-run, or pass --force.",
|
|
395
|
+
file=sys.stderr,
|
|
396
|
+
)
|
|
397
|
+
return 3
|
|
398
|
+
try:
|
|
399
|
+
result = cleanup_chat_artifacts(
|
|
400
|
+
paths,
|
|
401
|
+
ids,
|
|
402
|
+
vacuum=args.vacuum,
|
|
403
|
+
require_cursor_stopped=not args.force,
|
|
404
|
+
)
|
|
405
|
+
except _CLI_ERRORS as exc:
|
|
406
|
+
print(exc, file=sys.stderr)
|
|
407
|
+
return 2
|
|
408
|
+
print(
|
|
409
|
+
f"Cleanup complete: {result.search_rows} search rows, "
|
|
410
|
+
f"{result.transcript_dirs} transcript directories, "
|
|
411
|
+
f"{result.transcript_files} nested transcript files."
|
|
412
|
+
)
|
|
413
|
+
if result.vacuumed:
|
|
414
|
+
print("Database vacuumed.")
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _cmd_view(args, paths: CursorPaths) -> int:
|
|
419
|
+
try:
|
|
420
|
+
chat, messages = load_conversation(paths, args.composer_id)
|
|
421
|
+
except _CLI_ERRORS as exc:
|
|
422
|
+
print(exc, file=sys.stderr)
|
|
423
|
+
return 2
|
|
424
|
+
if args.json:
|
|
425
|
+
print(
|
|
426
|
+
json.dumps(
|
|
427
|
+
{
|
|
428
|
+
**_chat_json(chat),
|
|
429
|
+
"messages": [
|
|
430
|
+
{
|
|
431
|
+
"role": message.role,
|
|
432
|
+
"text": message.text,
|
|
433
|
+
"tool_name": message.tool_name,
|
|
434
|
+
}
|
|
435
|
+
for message in messages
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
indent=2,
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
return 0
|
|
442
|
+
print(format_conversation(chat, messages, thinking=args.thinking), end="")
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _chat_json(chat) -> dict:
|
|
447
|
+
return {
|
|
448
|
+
"id": chat.composer_id,
|
|
449
|
+
"title": chat.title,
|
|
450
|
+
"workspace_id": chat.workspace_id,
|
|
451
|
+
"workspace": chat.workspace_path,
|
|
452
|
+
"repo": chat.repo,
|
|
453
|
+
"updated_at": chat.updated_at.isoformat(timespec="seconds"),
|
|
454
|
+
"size_bytes": chat.size_bytes,
|
|
455
|
+
"is_archived": chat.is_archived,
|
|
456
|
+
"subcomposer_ids": chat.subcomposer_ids,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _print_repos(chats, *, as_json: bool) -> None:
|
|
461
|
+
rows = summarize_repos(chats)
|
|
462
|
+
if as_json:
|
|
463
|
+
print(json.dumps(rows, indent=2))
|
|
464
|
+
return
|
|
465
|
+
if not rows:
|
|
466
|
+
print("No chats found.")
|
|
467
|
+
return
|
|
468
|
+
print(f"{'REPO':<24} {'CHATS':>5} {'ARCH':>5}")
|
|
469
|
+
for row in rows:
|
|
470
|
+
repo = str(row["repo"])
|
|
471
|
+
if len(repo) > 24:
|
|
472
|
+
repo = repo[:23] + "…"
|
|
473
|
+
print(f"{repo:<24} {row['chats']:>5} {row['archived']:>5}")
|
|
474
|
+
print()
|
|
475
|
+
print(f"{len(rows)} repo(s), {sum(int(row['chats']) for row in rows)} chat(s)")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _print_table(chats) -> None:
|
|
479
|
+
if not chats:
|
|
480
|
+
print("No chats found.")
|
|
481
|
+
return
|
|
482
|
+
print(f"{'ARCH':<5} {'UPDATED':<17} {'SIZE':>8} {'REPO':<16} {'ID':<36} TITLE")
|
|
483
|
+
for chat in chats:
|
|
484
|
+
title = chat.title.replace("\n", " ")
|
|
485
|
+
repo = chat.repo
|
|
486
|
+
if len(repo) > 16:
|
|
487
|
+
repo = repo[:15] + "…"
|
|
488
|
+
print(
|
|
489
|
+
f"{'yes' if chat.is_archived else 'no':<5} "
|
|
490
|
+
f"{chat.updated_at.strftime('%Y-%m-%d %H:%M'):<17} "
|
|
491
|
+
f"{format_bytes(chat.size_bytes):>8} "
|
|
492
|
+
f"{repo:<16} "
|
|
493
|
+
f"{chat.composer_id:<36} {title}"
|
|
494
|
+
)
|
|
495
|
+
archived = sum(1 for chat in chats if chat.is_archived)
|
|
496
|
+
print()
|
|
497
|
+
print(
|
|
498
|
+
f"{len(chats)} chat(s) ({archived} archived), "
|
|
499
|
+
f"{format_bytes(sum(c.size_bytes for c in chats))}"
|
|
500
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_REQUIRED_TABLES = {
|
|
8
|
+
"ItemTable": frozenset({"key", "value"}),
|
|
9
|
+
"cursorDiskKV": frozenset({"key", "value"}),
|
|
10
|
+
"composerHeaders": frozenset({"composerId", "isArchived", "isSubagent", "value"}),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
_KV_PREFIXES = (
|
|
14
|
+
"composerData:{id}",
|
|
15
|
+
"bubbleId:{id}:",
|
|
16
|
+
"checkpointId:{id}:",
|
|
17
|
+
"ofsContent:{id}:",
|
|
18
|
+
"codeBlockPartialInlineDiffFates:{id}:",
|
|
19
|
+
"composerVirtualRowHeights:{id}",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_ITEM_KEYS = ("glass/cursor/{id}",)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def table_exists(connection: sqlite3.Connection, name: str) -> bool:
|
|
26
|
+
row = connection.execute(
|
|
27
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
28
|
+
(name,),
|
|
29
|
+
).fetchone()
|
|
30
|
+
return row is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def table_columns(connection: sqlite3.Connection, table: str) -> set[str]:
|
|
34
|
+
return {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def kv_patterns(composer_id: str) -> list[tuple[str, bool]]:
|
|
38
|
+
patterns: list[tuple[str, bool]] = []
|
|
39
|
+
for template in _KV_PREFIXES:
|
|
40
|
+
key = template.format(id=composer_id)
|
|
41
|
+
if key.endswith(":"):
|
|
42
|
+
escaped = key.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
43
|
+
patterns.append((escaped + "%", True))
|
|
44
|
+
else:
|
|
45
|
+
patterns.append((key, False))
|
|
46
|
+
return patterns
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def item_keys(composer_id: str) -> list[str]:
|
|
50
|
+
return [template.format(id=composer_id) for template in _ITEM_KEYS]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def schema_problems(global_db: Path, search_db: Path) -> list[str]:
|
|
54
|
+
if not global_db.is_file():
|
|
55
|
+
return [f"Cursor database not found: {global_db}"]
|
|
56
|
+
|
|
57
|
+
problems: list[str] = []
|
|
58
|
+
connection = _connect_readonly(global_db)
|
|
59
|
+
try:
|
|
60
|
+
for table, required in _REQUIRED_TABLES.items():
|
|
61
|
+
if not table_exists(connection, table):
|
|
62
|
+
problems.append(f"missing table {table}")
|
|
63
|
+
continue
|
|
64
|
+
missing = required - table_columns(connection, table)
|
|
65
|
+
if missing:
|
|
66
|
+
problems.append(f"{table} missing columns: {', '.join(sorted(missing))}")
|
|
67
|
+
|
|
68
|
+
if table_exists(connection, "ItemTable"):
|
|
69
|
+
row = connection.execute(
|
|
70
|
+
"SELECT value FROM ItemTable "
|
|
71
|
+
"WHERE key = 'composer.composerHeaders.tableGateEnabled'"
|
|
72
|
+
).fetchone()
|
|
73
|
+
if row is None:
|
|
74
|
+
problems.append("composer.composerHeaders.tableGateEnabled is missing")
|
|
75
|
+
elif _decode(row["value"]).strip().lower() not in {"true", "1"}:
|
|
76
|
+
problems.append("composer.composerHeaders.tableGateEnabled is not true")
|
|
77
|
+
finally:
|
|
78
|
+
connection.close()
|
|
79
|
+
|
|
80
|
+
if search_db.is_file():
|
|
81
|
+
search = _connect_readonly(search_db)
|
|
82
|
+
try:
|
|
83
|
+
if not table_exists(search, "conversations"):
|
|
84
|
+
problems.append("conversation-search.db missing table conversations")
|
|
85
|
+
else:
|
|
86
|
+
missing = {"id", "is_archived", "fts_rowid"} - table_columns(
|
|
87
|
+
search,
|
|
88
|
+
"conversations",
|
|
89
|
+
)
|
|
90
|
+
if missing:
|
|
91
|
+
problems.append(
|
|
92
|
+
"conversations missing columns: " + ", ".join(sorted(missing))
|
|
93
|
+
)
|
|
94
|
+
if not table_exists(search, "conversation_fts"):
|
|
95
|
+
problems.append("conversation-search.db missing table conversation_fts")
|
|
96
|
+
finally:
|
|
97
|
+
search.close()
|
|
98
|
+
return problems
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def assert_writable_schema(global_db: Path, search_db: Path) -> None:
|
|
102
|
+
problems = schema_problems(global_db, search_db)
|
|
103
|
+
if problems:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"Refusing to change Cursor data; schema is not the expected "
|
|
106
|
+
"composerHeaders layout:\n - " + "\n - ".join(problems)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _connect_readonly(path: Path) -> sqlite3.Connection:
|
|
111
|
+
uri = path.resolve().as_uri() + "?mode=ro"
|
|
112
|
+
connection = sqlite3.connect(uri, uri=True)
|
|
113
|
+
connection.row_factory = sqlite3.Row
|
|
114
|
+
return connection
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _decode(value: object) -> str:
|
|
118
|
+
if isinstance(value, bytes):
|
|
119
|
+
return value.decode("utf-8", errors="replace")
|
|
120
|
+
return str(value or "")
|