spiracha 1.1.2 → 1.3.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.
Files changed (159) hide show
  1. package/AGENTS.md +57 -14
  2. package/README.md +122 -65
  3. package/apps/ui/AGENTS.md +18 -8
  4. package/apps/ui/README.md +30 -12
  5. package/apps/ui/dist/client/assets/{analytics-CqWZmyV6.js → analytics-B_hYz65v.js} +1 -1
  6. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-qiyygB7e.js +1 -0
  7. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-z1SQC2Kg.js +1 -0
  8. package/apps/ui/dist/client/assets/antigravity-keychain-panel-dYuRWtCf.js +1 -0
  9. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CliqUr7o.js +1 -0
  10. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CnoBzyX6.js +1 -0
  11. package/apps/ui/dist/client/assets/antigravity.index-CakfZz_E.js +1 -0
  12. package/apps/ui/dist/client/assets/antigravity.index-DY7M1KhG.js +1 -0
  13. package/apps/ui/dist/client/assets/badge-aHE9ETVe.js +1 -0
  14. package/apps/ui/dist/client/assets/checkbox-DN3XnJaA.js +1 -0
  15. package/apps/ui/dist/client/assets/cursor-threads._composerId-BMQyx8qG.js +1 -0
  16. package/apps/ui/dist/client/assets/cursor-threads._composerId-BTlaA-tV.js +1 -0
  17. package/apps/ui/dist/client/assets/cursor._workspaceKey-CrgrfevV.js +1 -0
  18. package/apps/ui/dist/client/assets/cursor._workspaceKey-bYS2syGL.js +1 -0
  19. package/apps/ui/dist/client/assets/cursor.index-CTqZMPYU.js +1 -0
  20. package/apps/ui/dist/client/assets/cursor.index-Clsz4E_e.js +2 -0
  21. package/apps/ui/dist/client/assets/{data-table-DnPYMPCD.js → data-table-Cj-v-uyB.js} +2 -2
  22. package/apps/ui/dist/client/assets/delete-confirm-dialog-DTpzBiNK.js +11 -0
  23. package/apps/ui/dist/client/assets/dist-BNAn99Pu.js +1 -0
  24. package/apps/ui/dist/client/assets/download-P3Rp23Ad.js +1 -0
  25. package/apps/ui/dist/client/assets/dropdown-menu-3qB5j9nt.js +1 -0
  26. package/apps/ui/dist/client/assets/es2015-Dwm_turD.js +41 -0
  27. package/apps/ui/dist/client/assets/export-dialog-CazdrASq.js +1 -0
  28. package/apps/ui/dist/client/assets/formatters-BdnWuM1z.js +1 -0
  29. package/apps/ui/dist/client/assets/index-BVFnfS78.js +22 -0
  30. package/apps/ui/dist/client/assets/json-panel-DLkS30sQ.js +1 -0
  31. package/apps/ui/dist/client/assets/metadata-section-jnIkB7dB.js +1 -0
  32. package/apps/ui/dist/client/assets/{metric-card-9jwBF7rG.js → metric-card-CBZuWLzQ.js} +1 -1
  33. package/apps/ui/dist/client/assets/page-header-CnD21cPn.js +1 -0
  34. package/apps/ui/dist/client/assets/projects._project-BLszwvYL.js +1 -0
  35. package/apps/ui/dist/client/assets/projects._project-DvLxYbvk.js +1 -0
  36. package/apps/ui/dist/client/assets/projects.index-COn8woBR.js +1 -0
  37. package/apps/ui/dist/client/assets/projects.index-DYs98skV.js +3 -0
  38. package/apps/ui/dist/client/assets/refresh-ccw-BDrYXjtD.js +1 -0
  39. package/apps/ui/dist/client/assets/reload-error-panel-DLAg0AW2.js +1 -0
  40. package/apps/ui/dist/client/assets/routes-BtF5-coe.js +1 -0
  41. package/apps/ui/dist/client/assets/scroll-text-CqaFm9by.js +1 -0
  42. package/apps/ui/dist/client/assets/select-DbnpwqL6.js +1 -0
  43. package/apps/ui/dist/client/assets/settings-CGX3VleN.js +1 -0
  44. package/apps/ui/dist/client/assets/styles-Ch0r3kMZ.css +1 -0
  45. package/apps/ui/dist/client/assets/text-document-panel-DPleOmmq.js +1 -0
  46. package/apps/ui/dist/client/assets/text-filter-7M6wRo-t.js +2 -0
  47. package/apps/ui/dist/client/assets/threads._threadId-D5w76IB-.js +7 -0
  48. package/apps/ui/dist/client/assets/{threads._threadId-DT75NiBa.js → threads._threadId-Dx85sI9P.js} +1 -1
  49. package/apps/ui/dist/client/assets/useMutation-MZ3Hr9h9.js +1 -0
  50. package/apps/ui/dist/client/assets/useQuery-Cb4V0AT0.js +1 -0
  51. package/apps/ui/dist/client/icon.svg +28 -0
  52. package/apps/ui/dist/client/manifest.json +6 -16
  53. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CBbkUXw6.js +227 -0
  54. package/apps/ui/dist/server/assets/{analytics-BMxW_bZL.js → analytics-CBNOYZwJ.js} +2 -2
  55. package/apps/ui/dist/server/assets/antigravity-conversation-state-HgzS302O.js +16 -0
  56. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-B9Rm4EXh.js +212 -0
  57. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-BIdYNy68.js +20 -0
  58. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-D426O-64.js +11 -0
  59. package/apps/ui/dist/server/assets/antigravity-db-D9gW1D8G.js +576 -0
  60. package/apps/ui/dist/server/assets/antigravity-keychain-DOiuHDwK.js +126 -0
  61. package/apps/ui/dist/server/assets/antigravity-keychain-panel-DcLyBBwd.js +55 -0
  62. package/apps/ui/dist/server/assets/antigravity-queries-CgQhlQ7J.js +37 -0
  63. package/apps/ui/dist/server/assets/antigravity-server-DFUx4Khk.js +114 -0
  64. package/apps/ui/dist/server/assets/antigravity._workspaceKey-3m_MzNFA.js +11 -0
  65. package/apps/ui/dist/server/assets/antigravity._workspaceKey-D42ixtzp.js +210 -0
  66. package/apps/ui/dist/server/assets/antigravity._workspaceKey-DnSlSC-C.js +28 -0
  67. package/apps/ui/dist/server/assets/antigravity.index-DZVT-cac.js +104 -0
  68. package/apps/ui/dist/server/assets/antigravity.index-DudTB3Tq.js +11 -0
  69. package/apps/ui/dist/server/assets/badge-EvdhKK_Z.js +26 -0
  70. package/apps/ui/dist/server/assets/{codex-queries-CAF6HYiG.js → codex-queries-eOJGfHQj.js} +6 -18
  71. package/apps/ui/dist/server/assets/{codex-server-C01sv0JJ.js → codex-server-nrETIF--.js} +166 -226
  72. package/apps/ui/dist/server/assets/createServerRpc-BtXIw2iP.js +12 -0
  73. package/apps/ui/dist/server/assets/createSsrRpc-COf5Zuye.js +16 -0
  74. package/apps/ui/dist/server/assets/cursor-db-B7agkAvM.js +643 -0
  75. package/apps/ui/dist/server/assets/cursor-exporter-types-CI3goo-c.js +34 -0
  76. package/apps/ui/dist/server/assets/cursor-queries-BMhuJeUO.js +65 -0
  77. package/apps/ui/dist/server/assets/cursor-recovery-9bJLs7vG.js +361 -0
  78. package/apps/ui/dist/server/assets/cursor-server-BgylIFgn.js +184 -0
  79. package/apps/ui/dist/server/assets/cursor-threads._composerId-BB0Y_Mao.js +11 -0
  80. package/apps/ui/dist/server/assets/cursor-threads._composerId-BsxFKzoJ.js +218 -0
  81. package/apps/ui/dist/server/assets/cursor-threads._composerId-DXffY_CK.js +18 -0
  82. package/apps/ui/dist/server/assets/cursor-transcript-2iL3KFSK.js +125 -0
  83. package/apps/ui/dist/server/assets/cursor._workspaceKey-BP2J1x_x.js +28 -0
  84. package/apps/ui/dist/server/assets/cursor._workspaceKey-BQd0e-Pd.js +399 -0
  85. package/apps/ui/dist/server/assets/cursor._workspaceKey-nmg3YIOQ.js +11 -0
  86. package/apps/ui/dist/server/assets/cursor.index-CQVxtCm8.js +189 -0
  87. package/apps/ui/dist/server/assets/cursor.index-CcsX7DG0.js +11 -0
  88. package/apps/ui/dist/server/assets/{delete-confirm-dialog-CWqcTXTF.js → delete-confirm-dialog-PCD7S0_M.js} +5 -4
  89. package/apps/ui/dist/server/assets/download-DMmiy1xf.js +92 -0
  90. package/apps/ui/dist/server/assets/{input-B4tEzctc.js → dropdown-menu-Dy_9t6TN.js} +1 -11
  91. package/apps/ui/dist/server/assets/{download-C5rkk_Bo.js → export-dialog-DaPlOGFT.js} +8 -99
  92. package/apps/ui/dist/server/assets/json-panel-RYsxWFae.js +16 -0
  93. package/apps/ui/dist/server/assets/{loading-panel-DbLdvjtR.js → loading-panel-BGFnWseS.js} +1 -1
  94. package/apps/ui/dist/server/assets/metadata-section-D6Lbc7D6.js +54 -0
  95. package/apps/ui/dist/server/assets/page-header-VNSaM3xd.js +29 -0
  96. package/apps/ui/dist/server/assets/projects._project-Bshqk7JA.js +12 -0
  97. package/apps/ui/dist/server/assets/{projects._project-CJ7l0ynC.js → projects._project-DUN3iWfg.js} +4 -4
  98. package/apps/ui/dist/server/assets/{projects._project-CcJLp_A8.js → projects._project-Dim9Y0kD.js} +54 -26
  99. package/apps/ui/dist/server/assets/projects.index-BLXOx5eL.js +12 -0
  100. package/apps/ui/dist/server/assets/{projects.index-srtogpuF.js → projects.index-DjSQK5dm.js} +23 -27
  101. package/apps/ui/dist/server/assets/{projects.index-CaplpeMy.js → reload-error-panel-BJMxY3U1.js} +5 -6
  102. package/apps/ui/dist/server/assets/{router-C_w-haH6.js → router-DrDgc-LD.js} +131 -44
  103. package/apps/ui/dist/server/assets/{routes-CPe-ppmC.js → routes-B-GlEe2C.js} +54 -39
  104. package/apps/ui/dist/server/assets/{routes-BhbxvJE7.js → routes-CNHAUMwo.js} +2 -2
  105. package/apps/ui/dist/server/assets/{settings-MvWDgc1u.js → settings-OayxIYQQ.js} +1 -1
  106. package/apps/ui/dist/server/assets/shared-CPRNYIql.js +134 -0
  107. package/apps/ui/dist/server/assets/text-document-panel-D8JbQWAn.js +23 -0
  108. package/apps/ui/dist/server/assets/text-filter-CGKxMCKt.js +36 -0
  109. package/apps/ui/dist/server/assets/{threads._threadId-Ba7vv6-K.js → threads._threadId-CJzm4KrZ.js} +3 -3
  110. package/apps/ui/dist/server/assets/{threads._threadId-euyNckhj.js → threads._threadId-DODTYddm.js} +69 -76
  111. package/apps/ui/dist/server/server.js +83 -36
  112. package/bin/codex-chats-claude.js +5 -0
  113. package/bin/codex-chats.js +5 -0
  114. package/bin/spiracha.js +5 -0
  115. package/package.json +26 -13
  116. package/src/export-cursor.ts +244 -0
  117. package/src/lib/antigravity-db.ts +936 -0
  118. package/src/lib/antigravity-exporter-types.ts +70 -0
  119. package/src/lib/antigravity-keychain.ts +203 -0
  120. package/src/lib/codex-browser-db.ts +7 -1
  121. package/src/lib/codex-browser-export.ts +2 -2
  122. package/src/lib/codex-browser-types.ts +22 -1
  123. package/src/lib/codex-exporter-cli.ts +9 -9
  124. package/src/lib/codex-exporter-transcript.ts +16 -190
  125. package/src/lib/codex-exporter-types.ts +1 -1
  126. package/src/lib/codex-exporter.ts +0 -1
  127. package/src/lib/codex-thread-recovery.ts +202 -0
  128. package/src/lib/cursor-db.ts +1096 -0
  129. package/src/lib/cursor-exporter-types.ts +190 -0
  130. package/src/lib/cursor-exporter.ts +266 -0
  131. package/src/lib/cursor-recovery.ts +543 -0
  132. package/src/lib/cursor-transcript.ts +183 -0
  133. package/src/lib/interactive-cli.ts +2 -2
  134. package/src/mcp-server.ts +2 -2
  135. package/src/spiracha.ts +16 -3
  136. package/src/ui-cli.ts +2 -2
  137. package/apps/ui/dist/client/assets/checkbox-DXM4lkJq.js +0 -1
  138. package/apps/ui/dist/client/assets/delete-confirm-dialog-CcZaRX33.js +0 -11
  139. package/apps/ui/dist/client/assets/download-DOwxk-cG.js +0 -1
  140. package/apps/ui/dist/client/assets/es2015-Bm0kEzx2.js +0 -41
  141. package/apps/ui/dist/client/assets/formatters-C12LmYaa.js +0 -1
  142. package/apps/ui/dist/client/assets/index-DdJ7ahIt.js +0 -22
  143. package/apps/ui/dist/client/assets/input-CEsI7EpI.js +0 -1
  144. package/apps/ui/dist/client/assets/page-header-Dr_h1CVv.js +0 -1
  145. package/apps/ui/dist/client/assets/projects._project-uyNGnpjH.js +0 -1
  146. package/apps/ui/dist/client/assets/projects._project-zoM8d2nH.js +0 -1
  147. package/apps/ui/dist/client/assets/projects.index-D1CWVN-O.js +0 -1
  148. package/apps/ui/dist/client/assets/projects.index-DukMuny6.js +0 -1
  149. package/apps/ui/dist/client/assets/routes-Gr2Wwh83.js +0 -1
  150. package/apps/ui/dist/client/assets/select-CFim44gT.js +0 -1
  151. package/apps/ui/dist/client/assets/settings-DqhyDxo2.js +0 -1
  152. package/apps/ui/dist/client/assets/styles-CMrP9Jb4.css +0 -1
  153. package/apps/ui/dist/client/assets/threads._threadId-Df5VXIuZ.js +0 -7
  154. package/apps/ui/dist/client/favicon.ico +0 -0
  155. package/apps/ui/dist/client/logo192.png +0 -0
  156. package/apps/ui/dist/client/logo512.png +0 -0
  157. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-C0V305Nt.js +0 -99
  158. package/apps/ui/dist/server/assets/page-header-CxdZM86z.js +0 -25
  159. package/apps/ui/dist/server/assets/projects._project-CLSohrBp.js +0 -26
@@ -0,0 +1,543 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { rm } from 'node:fs/promises';
3
+ import {
4
+ findCursorTranscriptDirs,
5
+ invalidateCursorDiscoveryCache,
6
+ listCursorWorkspaceGroups,
7
+ loadGlobalComposerHeaders,
8
+ openCursorReadonlyDb,
9
+ } from './cursor-db';
10
+ import {
11
+ COMPOSER_DATA_KEY,
12
+ COMPOSER_HEADERS_KEY,
13
+ type CursorPruneResult,
14
+ type CursorRecoverResult,
15
+ type CursorThreadSummary,
16
+ type CursorWorkspaceBucket,
17
+ type CursorWorkspaceGroup,
18
+ getCursorGlobalDbPath,
19
+ resolveCursorUserDir,
20
+ } from './cursor-exporter-types';
21
+
22
+ type ComposerEntry = {
23
+ composerId?: string;
24
+ name?: string;
25
+ type?: string;
26
+ lastUpdatedAt?: number;
27
+ createdAt?: number;
28
+ totalLinesAdded?: number;
29
+ workspaceIdentifier?: { id?: string; uri?: unknown } | null;
30
+ [key: string]: unknown;
31
+ };
32
+
33
+ type ComposerData = {
34
+ allComposers?: ComposerEntry[];
35
+ selectedComposerIds?: string[];
36
+ lastFocusedComposerIds?: string[];
37
+ hasMigratedComposerData?: boolean;
38
+ hasMigratedMultipleComposers?: boolean;
39
+ };
40
+
41
+ type BucketComposerDataSnapshot = {
42
+ data: ComposerData;
43
+ exists: boolean;
44
+ };
45
+
46
+ export const isCursorRunning = async (): Promise<boolean> => {
47
+ const proc = Bun.spawn(['pgrep', '-x', 'Cursor'], { stderr: 'ignore', stdout: 'ignore' });
48
+ return (await proc.exited) === 0;
49
+ };
50
+
51
+ const backupStamp = (): string => new Date().toISOString().replace(/[-:]/gu, '').replace(/\..+/u, '').replace('T', '-');
52
+
53
+ // The Cursor global DB can be multiple gigabytes, so copying the whole file per operation is not
54
+ // viable. We instead write small, targeted JSON backups of only the data each operation touches.
55
+ const backupComposerHeaders = async (globalDbPath: string): Promise<string> => {
56
+ const db = openCursorReadonlyDb(globalDbPath);
57
+ let headers: unknown;
58
+ try {
59
+ headers = readJsonItem(db, COMPOSER_HEADERS_KEY) ?? { allComposers: [] };
60
+ } finally {
61
+ db.close();
62
+ }
63
+
64
+ const backupPath = `${globalDbPath}.composerHeaders.${backupStamp()}.json`;
65
+ await Bun.write(backupPath, JSON.stringify(headers));
66
+ return backupPath;
67
+ };
68
+
69
+ const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]): Promise<string> => {
70
+ const db = openCursorReadonlyDb(globalDbPath);
71
+ try {
72
+ const dump = composerIds.map((composerId) => ({
73
+ bubbles: db.query('SELECT key, value FROM cursorDiskKV WHERE key LIKE ?').all(`bubbleId:${composerId}:%`),
74
+ composerData: readJsonItemFromKv(db, `composerData:${composerId}`),
75
+ composerId,
76
+ }));
77
+ const backupPath = `${globalDbPath}.prunedThreads.${backupStamp()}.json`;
78
+ await Bun.write(backupPath, JSON.stringify(dump));
79
+ return backupPath;
80
+ } finally {
81
+ db.close();
82
+ }
83
+ };
84
+
85
+ const readJsonItemFromKv = (db: Database, key: string): unknown => {
86
+ const row = db.query('SELECT value FROM cursorDiskKV WHERE key = ?').get(key) as { value?: string } | null;
87
+ if (!row?.value) {
88
+ return null;
89
+ }
90
+
91
+ try {
92
+ return JSON.parse(row.value);
93
+ } catch {
94
+ return null;
95
+ }
96
+ };
97
+
98
+ const readJsonItem = <T>(db: Database, key: string): T | null => {
99
+ const row = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as { value?: string } | null;
100
+ if (!row?.value) {
101
+ return null;
102
+ }
103
+
104
+ try {
105
+ return JSON.parse(row.value) as T;
106
+ } catch {
107
+ return null;
108
+ }
109
+ };
110
+
111
+ const writeJsonItem = (db: Database, key: string, value: unknown): void => {
112
+ db.run(
113
+ `INSERT INTO ItemTable (key, value) VALUES (?, ?)
114
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
115
+ [key, JSON.stringify(value)],
116
+ );
117
+ };
118
+
119
+ const scoreComposer = (entry: ComposerEntry): number =>
120
+ Number(Boolean(entry.name)) + Number(entry.lastUpdatedAt ?? 0) + Number(entry.totalLinesAdded ?? 0);
121
+
122
+ const mergeComposerEntries = (entries: ComposerEntry[]): ComposerEntry[] => {
123
+ const byId = new Map<string, ComposerEntry>();
124
+ for (const entry of entries) {
125
+ const id = entry.composerId;
126
+ if (!id) {
127
+ continue;
128
+ }
129
+
130
+ const current = byId.get(id);
131
+ if (!current || scoreComposer(entry) >= scoreComposer(current)) {
132
+ byId.set(id, entry);
133
+ }
134
+ }
135
+
136
+ return [...byId.values()].sort(
137
+ (a, b) => Number(b.lastUpdatedAt ?? b.createdAt ?? 0) - Number(a.lastUpdatedAt ?? a.createdAt ?? 0),
138
+ );
139
+ };
140
+
141
+ const buildWorkspaceIdentifier = (bucket: CursorWorkspaceBucket): { id: string; uri?: unknown } => {
142
+ if (bucket.kind === 'folder' && bucket.folders[0]) {
143
+ const folder = bucket.folders[0];
144
+ return {
145
+ id: bucket.bucketId,
146
+ uri: {
147
+ $mid: 1,
148
+ external: `file://${folder}`,
149
+ fsPath: folder,
150
+ path: folder,
151
+ scheme: 'file',
152
+ },
153
+ };
154
+ }
155
+
156
+ return { id: bucket.bucketId };
157
+ };
158
+
159
+ const composersForBucket = (bucket: CursorWorkspaceBucket, headers: ComposerEntry[]): ComposerEntry[] => {
160
+ let fromBucket: ComposerEntry[] = [];
161
+ try {
162
+ const db = openCursorReadonlyDb(bucket.dbPath);
163
+ try {
164
+ fromBucket = readJsonItem<ComposerData>(db, COMPOSER_DATA_KEY)?.allComposers ?? [];
165
+ } finally {
166
+ db.close();
167
+ }
168
+ } catch {
169
+ fromBucket = [];
170
+ }
171
+
172
+ const linked = headers.filter((header) => header.workspaceIdentifier?.id === bucket.bucketId);
173
+ return mergeComposerEntries([...fromBucket, ...linked]);
174
+ };
175
+
176
+ const chooseTargetBucket = (
177
+ group: CursorWorkspaceGroup,
178
+ ): { target: CursorWorkspaceBucket; sources: CursorWorkspaceBucket[] } => {
179
+ const ranked = [...group.buckets].sort((a, b) => b.mtimeMs - a.mtimeMs || b.dbSizeBytes - a.dbSizeBytes);
180
+ const [target, ...sources] = ranked;
181
+ return { sources, target: target! };
182
+ };
183
+
184
+ const relinkHeaders = (
185
+ db: Database,
186
+ composers: ComposerEntry[],
187
+ sourceBucketIds: Set<string>,
188
+ target: CursorWorkspaceBucket,
189
+ ): { relinked: number; added: number } => {
190
+ const data = readJsonItem<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY) ?? { allComposers: [] };
191
+ const headers = data.allComposers ?? [];
192
+ const byId = new Map(headers.filter((header) => header.composerId).map((header) => [header.composerId!, header]));
193
+ const workspaceIdentifier = buildWorkspaceIdentifier(target);
194
+ let relinked = 0;
195
+ let added = 0;
196
+
197
+ for (const composer of composers) {
198
+ const id = composer.composerId;
199
+ if (!id) {
200
+ continue;
201
+ }
202
+
203
+ const existing = byId.get(id);
204
+ if (existing) {
205
+ const currentId = existing.workspaceIdentifier?.id;
206
+ if (currentId !== target.bucketId && (currentId === undefined || sourceBucketIds.has(currentId))) {
207
+ existing.workspaceIdentifier = workspaceIdentifier;
208
+ relinked += 1;
209
+ }
210
+ continue;
211
+ }
212
+
213
+ headers.push({ ...composer, type: composer.type ?? 'head', workspaceIdentifier });
214
+ byId.set(id, headers[headers.length - 1]!);
215
+ added += 1;
216
+ }
217
+
218
+ if (relinked > 0 || added > 0) {
219
+ headers.sort(
220
+ (a, b) => Number(b.lastUpdatedAt ?? b.createdAt ?? 0) - Number(a.lastUpdatedAt ?? a.createdAt ?? 0),
221
+ );
222
+ writeJsonItem(db, COMPOSER_HEADERS_KEY, { allComposers: headers });
223
+ }
224
+
225
+ return { added, relinked };
226
+ };
227
+
228
+ const countBubbles = (db: Database, composerId: string): number => {
229
+ const row = db
230
+ .query('SELECT COUNT(*) AS count FROM cursorDiskKV WHERE key LIKE ?')
231
+ .get(`bubbleId:${composerId}:%`) as { count: number };
232
+ return row.count;
233
+ };
234
+
235
+ export const recoverCursorWorkspaceGroup = async (
236
+ group: CursorWorkspaceGroup,
237
+ apply: boolean,
238
+ userDir = resolveCursorUserDir(),
239
+ ): Promise<CursorRecoverResult> => {
240
+ if (group.buckets.length === 0) {
241
+ throw new Error(
242
+ `"${group.label}" has no on-disk Cursor storage bucket to recover into. Its threads can still be exported or deleted.`,
243
+ );
244
+ }
245
+
246
+ const globalDbPath = getCursorGlobalDbPath(userDir);
247
+ const headers = loadGlobalComposerHeaders(globalDbPath);
248
+ const { target, sources } = chooseTargetBucket(group);
249
+ const sourceBucketIds = new Set(sources.map((bucket) => bucket.bucketId));
250
+
251
+ const merged = mergeComposerEntries([
252
+ ...composersForBucket(target, headers),
253
+ ...sources.flatMap((bucket) => composersForBucket(bucket, headers)),
254
+ ]);
255
+
256
+ if (!apply) {
257
+ return buildRecoverResult(group, target, merged, globalDbPath, 0, merged.length);
258
+ }
259
+
260
+ const currentBucketData = readTargetBucketComposerData(target);
261
+ await backupComposerHeaders(globalDbPath);
262
+ await backupTargetBucketComposerData(target, currentBucketData);
263
+
264
+ const db = new Database(globalDbPath);
265
+ let committed = false;
266
+ let relinked = 0;
267
+ let added = 0;
268
+ try {
269
+ db.exec('BEGIN IMMEDIATE');
270
+ writeTargetBucketComposerData(target, buildTargetBucketComposerData(currentBucketData.data, merged));
271
+ ({ relinked, added } = relinkHeaders(db, merged, sourceBucketIds, target));
272
+ db.exec('COMMIT');
273
+ committed = true;
274
+ } catch (error) {
275
+ if (!committed) {
276
+ try {
277
+ db.exec('ROLLBACK');
278
+ } catch {}
279
+
280
+ try {
281
+ writeTargetBucketComposerData(target, currentBucketData);
282
+ } catch {}
283
+ }
284
+
285
+ throw error;
286
+ } finally {
287
+ db.close();
288
+ invalidateCursorDiscoveryCache();
289
+ }
290
+
291
+ return buildRecoverResult(group, target, merged, globalDbPath, relinked, added);
292
+ };
293
+
294
+ // Non-migrated workspaces read their thread list from the bucket's composer.composerData rather than
295
+ // the global headers, so we write the merged threads into the active bucket as well as relinking
296
+ // global headers. This mirrors what Cursor itself stores and makes recovery work for both layouts.
297
+ const readTargetBucketComposerData = (target: CursorWorkspaceBucket): BucketComposerDataSnapshot => {
298
+ const db = openCursorReadonlyDb(target.dbPath);
299
+ try {
300
+ const data = readJsonItem<ComposerData>(db, COMPOSER_DATA_KEY);
301
+ return {
302
+ data: data ?? {},
303
+ exists: data !== null,
304
+ };
305
+ } finally {
306
+ db.close();
307
+ }
308
+ };
309
+
310
+ const backupTargetBucketComposerData = async (
311
+ target: CursorWorkspaceBucket,
312
+ snapshot: BucketComposerDataSnapshot,
313
+ ): Promise<string> => {
314
+ const backupPath = `${target.dbPath}.composerData.${backupStamp()}.json`;
315
+ await Bun.write(backupPath, JSON.stringify(snapshot));
316
+ return backupPath;
317
+ };
318
+
319
+ const buildTargetBucketComposerData = (existing: ComposerData, merged: ComposerEntry[]): ComposerData => {
320
+ const selectedIds = merged.map((entry) => entry.composerId).filter((value): value is string => Boolean(value));
321
+
322
+ return {
323
+ ...existing,
324
+ allComposers: merged,
325
+ hasMigratedComposerData: true,
326
+ hasMigratedMultipleComposers: true,
327
+ lastFocusedComposerIds: selectedIds.slice(0, 1),
328
+ selectedComposerIds: selectedIds.slice(0, 5),
329
+ };
330
+ };
331
+
332
+ const writeTargetBucketComposerData = (
333
+ target: CursorWorkspaceBucket,
334
+ snapshot: BucketComposerDataSnapshot | ComposerData,
335
+ ): void => {
336
+ const db = new Database(target.dbPath);
337
+ try {
338
+ if ('exists' in snapshot && !snapshot.exists) {
339
+ db.run('DELETE FROM ItemTable WHERE key = ?', [COMPOSER_DATA_KEY]);
340
+ return;
341
+ }
342
+
343
+ writeJsonItem(db, COMPOSER_DATA_KEY, 'exists' in snapshot ? snapshot.data : snapshot);
344
+ } finally {
345
+ db.close();
346
+ }
347
+ };
348
+
349
+ const buildRecoverResult = (
350
+ group: CursorWorkspaceGroup,
351
+ target: CursorWorkspaceBucket,
352
+ merged: ComposerEntry[],
353
+ globalDbPath: string,
354
+ relinked: number,
355
+ added: number,
356
+ ): CursorRecoverResult => {
357
+ const db = openCursorReadonlyDb(globalDbPath);
358
+ try {
359
+ return {
360
+ activeBucketId: target.bucketId,
361
+ addedHeaderCount: added,
362
+ mergedThreadCount: merged.length,
363
+ relinkedHeaderCount: relinked,
364
+ threads: merged
365
+ .filter((entry) => entry.composerId)
366
+ .map((entry) => ({
367
+ bubbleCount: countBubbles(db, entry.composerId as string),
368
+ composerId: entry.composerId as string,
369
+ name: typeof entry.name === 'string' && entry.name ? entry.name : '(untitled)',
370
+ })),
371
+ workspaceKey: group.key,
372
+ };
373
+ } finally {
374
+ db.close();
375
+ }
376
+ };
377
+
378
+ const removeThreadFromBucket = (db: Database, composerIds: Set<string>): boolean => {
379
+ const data = readJsonItem<ComposerData>(db, COMPOSER_DATA_KEY);
380
+ if (!data?.allComposers?.length) {
381
+ return false;
382
+ }
383
+
384
+ const before = data.allComposers.length;
385
+ data.allComposers = data.allComposers.filter((entry) => !composerIds.has(entry.composerId ?? ''));
386
+ if (data.allComposers.length === before) {
387
+ return false;
388
+ }
389
+
390
+ data.selectedComposerIds = (data.selectedComposerIds ?? []).filter((id) => !composerIds.has(id));
391
+ data.lastFocusedComposerIds = (data.lastFocusedComposerIds ?? []).filter((id) => !composerIds.has(id));
392
+ writeJsonItem(db, COMPOSER_DATA_KEY, data);
393
+ return true;
394
+ };
395
+
396
+ const pruneGlobalThread = (db: Database, composerId: string): { bubbles: number; composerData: number } => {
397
+ const bubbleResult = db.run('DELETE FROM cursorDiskKV WHERE key LIKE ?', [`bubbleId:${composerId}:%`]);
398
+ const headResult = db.run('DELETE FROM cursorDiskKV WHERE key = ?', [`composerData:${composerId}`]);
399
+ return { bubbles: bubbleResult.changes ?? 0, composerData: headResult.changes ?? 0 };
400
+ };
401
+
402
+ const removeThreadHeaders = (db: Database, composerIds: Set<string>): number => {
403
+ const data = readJsonItem<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY);
404
+ if (!data?.allComposers?.length) {
405
+ return 0;
406
+ }
407
+
408
+ const before = data.allComposers.length;
409
+ data.allComposers = data.allComposers.filter((entry) => !composerIds.has(entry.composerId ?? ''));
410
+ const removed = before - data.allComposers.length;
411
+ if (removed > 0) {
412
+ writeJsonItem(db, COMPOSER_HEADERS_KEY, data);
413
+ }
414
+
415
+ return removed;
416
+ };
417
+
418
+ export const pruneCursorThreads = async (
419
+ threads: CursorThreadSummary[],
420
+ apply: boolean,
421
+ userDir = resolveCursorUserDir(),
422
+ ): Promise<CursorPruneResult> => {
423
+ const composerIds = new Set(threads.map((thread) => thread.composerId));
424
+ const globalDbPath = getCursorGlobalDbPath(userDir);
425
+ const result: CursorPruneResult = {
426
+ bubblesDeleted: 0,
427
+ composerDataDeleted: 0,
428
+ composerIds: [...composerIds],
429
+ headersRemoved: 0,
430
+ transcriptDirsRemoved: 0,
431
+ workspaceBucketsUpdated: 0,
432
+ };
433
+
434
+ if (composerIds.size === 0) {
435
+ return result;
436
+ }
437
+
438
+ if (!apply) {
439
+ result.bubblesDeleted = threads.reduce((sum, thread) => sum + thread.bubbleCount, 0);
440
+ result.composerDataDeleted = threads.length;
441
+ result.headersRemoved = threads.length;
442
+ result.transcriptDirsRemoved = threads.reduce((sum, thread) => sum + thread.transcriptDirs.length, 0);
443
+ return result;
444
+ }
445
+
446
+ await backupPrunedThreads(globalDbPath, [...composerIds]);
447
+ await pruneGlobalThreads(globalDbPath, threads, composerIds, result);
448
+ await pruneWorkspaceBuckets(threads, composerIds, result, userDir);
449
+ await pruneTranscriptDirs(threads, result);
450
+ invalidateCursorDiscoveryCache();
451
+
452
+ return result;
453
+ };
454
+
455
+ const pruneGlobalThreads = async (
456
+ globalDbPath: string,
457
+ threads: CursorThreadSummary[],
458
+ composerIds: Set<string>,
459
+ result: CursorPruneResult,
460
+ ): Promise<void> => {
461
+ const db = new Database(globalDbPath);
462
+ try {
463
+ for (const thread of threads) {
464
+ const deleted = pruneGlobalThread(db, thread.composerId);
465
+ result.bubblesDeleted += deleted.bubbles;
466
+ result.composerDataDeleted += deleted.composerData;
467
+ }
468
+
469
+ result.headersRemoved = removeThreadHeaders(db, composerIds);
470
+ } finally {
471
+ db.close();
472
+ }
473
+ };
474
+
475
+ const pruneWorkspaceBuckets = async (
476
+ _threads: CursorThreadSummary[],
477
+ composerIds: Set<string>,
478
+ result: CursorPruneResult,
479
+ userDir: string,
480
+ ): Promise<void> => {
481
+ // Scan every bucket: a thread can live in more than one bucket's composer.composerData (e.g. the
482
+ // current bucket plus the older bucket it was recovered from), so remove it from all of them.
483
+ const groups = await listCursorWorkspaceGroups(userDir);
484
+ const dbPaths = new Set<string>();
485
+ for (const group of groups) {
486
+ for (const bucket of group.buckets) {
487
+ dbPaths.add(bucket.dbPath);
488
+ }
489
+ }
490
+
491
+ for (const dbPath of dbPaths) {
492
+ const db = new Database(dbPath);
493
+ try {
494
+ if (removeThreadFromBucket(db, composerIds)) {
495
+ result.workspaceBucketsUpdated += 1;
496
+ }
497
+ } finally {
498
+ db.close();
499
+ }
500
+ }
501
+ };
502
+
503
+ const pruneTranscriptDirs = async (threads: CursorThreadSummary[], result: CursorPruneResult): Promise<void> => {
504
+ for (const thread of threads) {
505
+ for (const dir of thread.transcriptDirs) {
506
+ await rm(dir, { force: true, recursive: true });
507
+ result.transcriptDirsRemoved += 1;
508
+ }
509
+ }
510
+ };
511
+
512
+ // Builds the minimal thread records needed to fully delete the given composer ids (bubble counts for
513
+ // reporting and the on-disk transcript directories to remove). Used by the UI delete actions.
514
+ export const collectCursorThreadsForDeletion = async (
515
+ composerIds: string[],
516
+ userDir = resolveCursorUserDir(),
517
+ ): Promise<CursorThreadSummary[]> => {
518
+ const globalDbPath = getCursorGlobalDbPath(userDir);
519
+ const db = openCursorReadonlyDb(globalDbPath);
520
+ const summaries: CursorThreadSummary[] = [];
521
+
522
+ try {
523
+ for (const composerId of composerIds) {
524
+ summaries.push({
525
+ bubbleBytes: 0,
526
+ bubbleCount: countBubbles(db, composerId),
527
+ bucketId: null,
528
+ composerId,
529
+ createdAtMs: null,
530
+ lastUpdatedAtMs: null,
531
+ mode: null,
532
+ name: '',
533
+ transcriptDirs: await findCursorTranscriptDirs(composerId, userDir),
534
+ workspaceKey: '',
535
+ workspaceLabel: '',
536
+ });
537
+ }
538
+ } finally {
539
+ db.close();
540
+ }
541
+
542
+ return summaries;
543
+ };