spiracha 1.2.0 → 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.
- package/AGENTS.md +49 -12
- package/README.md +117 -64
- package/apps/ui/AGENTS.md +16 -8
- package/apps/ui/README.md +28 -12
- package/apps/ui/dist/client/assets/{analytics-Cv0JMDN2.js → analytics-B_hYz65v.js} +1 -1
- package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-qiyygB7e.js +1 -0
- package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-z1SQC2Kg.js +1 -0
- package/apps/ui/dist/client/assets/antigravity-keychain-panel-dYuRWtCf.js +1 -0
- package/apps/ui/dist/client/assets/antigravity._workspaceKey-CliqUr7o.js +1 -0
- package/apps/ui/dist/client/assets/antigravity._workspaceKey-CnoBzyX6.js +1 -0
- package/apps/ui/dist/client/assets/antigravity.index-CakfZz_E.js +1 -0
- package/apps/ui/dist/client/assets/antigravity.index-DY7M1KhG.js +1 -0
- package/apps/ui/dist/client/assets/badge-aHE9ETVe.js +1 -0
- package/apps/ui/dist/client/assets/checkbox-DN3XnJaA.js +1 -0
- package/apps/ui/dist/client/assets/cursor-threads._composerId-BMQyx8qG.js +1 -0
- package/apps/ui/dist/client/assets/cursor-threads._composerId-BTlaA-tV.js +1 -0
- package/apps/ui/dist/client/assets/cursor._workspaceKey-CrgrfevV.js +1 -0
- package/apps/ui/dist/client/assets/cursor._workspaceKey-bYS2syGL.js +1 -0
- package/apps/ui/dist/client/assets/cursor.index-CTqZMPYU.js +1 -0
- package/apps/ui/dist/client/assets/cursor.index-Clsz4E_e.js +2 -0
- package/apps/ui/dist/client/assets/{data-table-Bgnh7phF.js → data-table-Cj-v-uyB.js} +2 -2
- package/apps/ui/dist/client/assets/delete-confirm-dialog-DTpzBiNK.js +11 -0
- package/apps/ui/dist/client/assets/dist-BNAn99Pu.js +1 -0
- package/apps/ui/dist/client/assets/download-P3Rp23Ad.js +1 -0
- package/apps/ui/dist/client/assets/dropdown-menu-3qB5j9nt.js +1 -0
- package/apps/ui/dist/client/assets/es2015-Dwm_turD.js +41 -0
- package/apps/ui/dist/client/assets/export-dialog-CazdrASq.js +1 -0
- package/apps/ui/dist/client/assets/formatters-BdnWuM1z.js +1 -0
- package/apps/ui/dist/client/assets/index-BVFnfS78.js +22 -0
- package/apps/ui/dist/client/assets/json-panel-DLkS30sQ.js +1 -0
- package/apps/ui/dist/client/assets/metadata-section-jnIkB7dB.js +1 -0
- package/apps/ui/dist/client/assets/{metric-card-BJX5rkHK.js → metric-card-CBZuWLzQ.js} +1 -1
- package/apps/ui/dist/client/assets/page-header-CnD21cPn.js +1 -0
- package/apps/ui/dist/client/assets/projects._project-BLszwvYL.js +1 -0
- package/apps/ui/dist/client/assets/projects._project-DvLxYbvk.js +1 -0
- package/apps/ui/dist/client/assets/projects.index-COn8woBR.js +1 -0
- package/apps/ui/dist/client/assets/projects.index-DYs98skV.js +3 -0
- package/apps/ui/dist/client/assets/refresh-ccw-BDrYXjtD.js +1 -0
- package/apps/ui/dist/client/assets/reload-error-panel-DLAg0AW2.js +1 -0
- package/apps/ui/dist/client/assets/routes-BtF5-coe.js +1 -0
- package/apps/ui/dist/client/assets/scroll-text-CqaFm9by.js +1 -0
- package/apps/ui/dist/client/assets/select-DbnpwqL6.js +1 -0
- package/apps/ui/dist/client/assets/settings-CGX3VleN.js +1 -0
- package/apps/ui/dist/client/assets/styles-Ch0r3kMZ.css +1 -0
- package/apps/ui/dist/client/assets/text-document-panel-DPleOmmq.js +1 -0
- package/apps/ui/dist/client/assets/text-filter-7M6wRo-t.js +2 -0
- package/apps/ui/dist/client/assets/threads._threadId-D5w76IB-.js +7 -0
- package/apps/ui/dist/client/assets/{threads._threadId-CUiCZSwo.js → threads._threadId-Dx85sI9P.js} +1 -1
- package/apps/ui/dist/client/assets/useMutation-MZ3Hr9h9.js +1 -0
- package/apps/ui/dist/client/assets/useQuery-Cb4V0AT0.js +1 -0
- package/apps/ui/dist/client/icon.svg +28 -0
- package/apps/ui/dist/client/manifest.json +6 -16
- package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CBbkUXw6.js +227 -0
- package/apps/ui/dist/server/assets/{analytics-2QpLKjlG.js → analytics-CBNOYZwJ.js} +2 -2
- package/apps/ui/dist/server/assets/antigravity-conversation-state-HgzS302O.js +16 -0
- package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-B9Rm4EXh.js +212 -0
- package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-BIdYNy68.js +20 -0
- package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-D426O-64.js +11 -0
- package/apps/ui/dist/server/assets/antigravity-db-D9gW1D8G.js +576 -0
- package/apps/ui/dist/server/assets/antigravity-keychain-DOiuHDwK.js +126 -0
- package/apps/ui/dist/server/assets/antigravity-keychain-panel-DcLyBBwd.js +55 -0
- package/apps/ui/dist/server/assets/antigravity-queries-CgQhlQ7J.js +37 -0
- package/apps/ui/dist/server/assets/antigravity-server-DFUx4Khk.js +114 -0
- package/apps/ui/dist/server/assets/antigravity._workspaceKey-3m_MzNFA.js +11 -0
- package/apps/ui/dist/server/assets/antigravity._workspaceKey-D42ixtzp.js +210 -0
- package/apps/ui/dist/server/assets/antigravity._workspaceKey-DnSlSC-C.js +28 -0
- package/apps/ui/dist/server/assets/antigravity.index-DZVT-cac.js +104 -0
- package/apps/ui/dist/server/assets/antigravity.index-DudTB3Tq.js +11 -0
- package/apps/ui/dist/server/assets/badge-EvdhKK_Z.js +26 -0
- package/apps/ui/dist/server/assets/{codex-queries-BH4Cb0v3.js → codex-queries-eOJGfHQj.js} +4 -16
- package/apps/ui/dist/server/assets/{codex-server-DqzruLmg.js → codex-server-nrETIF--.js} +149 -140
- package/apps/ui/dist/server/assets/createServerRpc-BtXIw2iP.js +12 -0
- package/apps/ui/dist/server/assets/createSsrRpc-COf5Zuye.js +16 -0
- package/apps/ui/dist/server/assets/cursor-db-B7agkAvM.js +643 -0
- package/apps/ui/dist/server/assets/cursor-exporter-types-CI3goo-c.js +34 -0
- package/apps/ui/dist/server/assets/cursor-queries-BMhuJeUO.js +65 -0
- package/apps/ui/dist/server/assets/cursor-recovery-9bJLs7vG.js +361 -0
- package/apps/ui/dist/server/assets/cursor-server-BgylIFgn.js +184 -0
- package/apps/ui/dist/server/assets/cursor-threads._composerId-BB0Y_Mao.js +11 -0
- package/apps/ui/dist/server/assets/cursor-threads._composerId-BsxFKzoJ.js +218 -0
- package/apps/ui/dist/server/assets/cursor-threads._composerId-DXffY_CK.js +18 -0
- package/apps/ui/dist/server/assets/cursor-transcript-2iL3KFSK.js +125 -0
- package/apps/ui/dist/server/assets/cursor._workspaceKey-BP2J1x_x.js +28 -0
- package/apps/ui/dist/server/assets/cursor._workspaceKey-BQd0e-Pd.js +399 -0
- package/apps/ui/dist/server/assets/cursor._workspaceKey-nmg3YIOQ.js +11 -0
- package/apps/ui/dist/server/assets/cursor.index-CQVxtCm8.js +189 -0
- package/apps/ui/dist/server/assets/cursor.index-CcsX7DG0.js +11 -0
- package/apps/ui/dist/server/assets/{delete-confirm-dialog-CWqcTXTF.js → delete-confirm-dialog-PCD7S0_M.js} +5 -4
- package/apps/ui/dist/server/assets/download-DMmiy1xf.js +92 -0
- package/apps/ui/dist/server/assets/{input-B4tEzctc.js → dropdown-menu-Dy_9t6TN.js} +1 -11
- package/apps/ui/dist/server/assets/{download-Drctxary.js → export-dialog-DaPlOGFT.js} +1 -92
- package/apps/ui/dist/server/assets/json-panel-RYsxWFae.js +16 -0
- package/apps/ui/dist/server/assets/{loading-panel-DbLdvjtR.js → loading-panel-BGFnWseS.js} +1 -1
- package/apps/ui/dist/server/assets/metadata-section-D6Lbc7D6.js +54 -0
- package/apps/ui/dist/server/assets/page-header-VNSaM3xd.js +29 -0
- package/apps/ui/dist/server/assets/projects._project-Bshqk7JA.js +12 -0
- package/apps/ui/dist/server/assets/{projects._project-gT01HBqH.js → projects._project-DUN3iWfg.js} +4 -4
- package/apps/ui/dist/server/assets/{projects._project-DreIU5b0.js → projects._project-Dim9Y0kD.js} +54 -26
- package/apps/ui/dist/server/assets/projects.index-BLXOx5eL.js +12 -0
- package/apps/ui/dist/server/assets/{projects.index-BYmgSGAj.js → projects.index-DjSQK5dm.js} +23 -27
- package/apps/ui/dist/server/assets/{projects.index-CaplpeMy.js → reload-error-panel-BJMxY3U1.js} +5 -6
- package/apps/ui/dist/server/assets/{router-Qj5Kn7bl.js → router-DrDgc-LD.js} +131 -44
- package/apps/ui/dist/server/assets/{routes-_LbCIjtJ.js → routes-B-GlEe2C.js} +54 -39
- package/apps/ui/dist/server/assets/{routes-BtcXuK0x.js → routes-CNHAUMwo.js} +2 -2
- package/apps/ui/dist/server/assets/{settings-MvWDgc1u.js → settings-OayxIYQQ.js} +1 -1
- package/apps/ui/dist/server/assets/shared-CPRNYIql.js +134 -0
- package/apps/ui/dist/server/assets/text-document-panel-D8JbQWAn.js +23 -0
- package/apps/ui/dist/server/assets/text-filter-CGKxMCKt.js +36 -0
- package/apps/ui/dist/server/assets/{threads._threadId-DcbAJkwf.js → threads._threadId-CJzm4KrZ.js} +3 -3
- package/apps/ui/dist/server/assets/{threads._threadId-D5m6ypGw.js → threads._threadId-DODTYddm.js} +69 -76
- package/apps/ui/dist/server/server.js +77 -13
- package/package.json +19 -9
- package/src/export-cursor.ts +244 -0
- package/src/lib/antigravity-db.ts +936 -0
- package/src/lib/antigravity-exporter-types.ts +70 -0
- package/src/lib/antigravity-keychain.ts +203 -0
- package/src/lib/codex-browser-db.ts +7 -1
- package/src/lib/codex-browser-types.ts +22 -1
- package/src/lib/codex-thread-recovery.ts +202 -0
- package/src/lib/cursor-db.ts +1096 -0
- package/src/lib/cursor-exporter-types.ts +190 -0
- package/src/lib/cursor-exporter.ts +266 -0
- package/src/lib/cursor-recovery.ts +543 -0
- package/src/lib/cursor-transcript.ts +183 -0
- package/src/spiracha.ts +16 -3
- package/src/ui-cli.ts +2 -2
- package/apps/ui/dist/client/assets/checkbox-DjHij7DJ.js +0 -1
- package/apps/ui/dist/client/assets/delete-confirm-dialog-CIZy_LXD.js +0 -11
- package/apps/ui/dist/client/assets/download-DQtfva4z.js +0 -1
- package/apps/ui/dist/client/assets/es2015-DsDKdYCE.js +0 -41
- package/apps/ui/dist/client/assets/formatters-CWFrMKSn.js +0 -1
- package/apps/ui/dist/client/assets/index-C_-e0lDI.js +0 -22
- package/apps/ui/dist/client/assets/input-BbgApiqZ.js +0 -1
- package/apps/ui/dist/client/assets/page-header-ODLuGLAB.js +0 -1
- package/apps/ui/dist/client/assets/projects._project-C2Pys_bB.js +0 -1
- package/apps/ui/dist/client/assets/projects._project-CHvAKvlu.js +0 -1
- package/apps/ui/dist/client/assets/projects.index-BmwtS1x-.js +0 -1
- package/apps/ui/dist/client/assets/projects.index-CuLw73mt.js +0 -1
- package/apps/ui/dist/client/assets/routes-CfnaTOlj.js +0 -1
- package/apps/ui/dist/client/assets/select-B1kH_5lx.js +0 -1
- package/apps/ui/dist/client/assets/settings-mYTB3sso.js +0 -1
- package/apps/ui/dist/client/assets/styles-CMrP9Jb4.css +0 -1
- package/apps/ui/dist/client/assets/threads._threadId-C_47okme.js +0 -7
- package/apps/ui/dist/client/favicon.ico +0 -0
- package/apps/ui/dist/client/logo192.png +0 -0
- package/apps/ui/dist/client/logo512.png +0 -0
- package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-kj_QB_26.js +0 -99
- package/apps/ui/dist/server/assets/page-header-CxdZM86z.js +0 -25
- package/apps/ui/dist/server/assets/projects._project-CLSohrBp.js +0 -26
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import { constants, Database } from 'bun:sqlite';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
COMPOSER_DATA_KEY,
|
|
6
|
+
COMPOSER_HEADERS_KEY,
|
|
7
|
+
type CursorBubble,
|
|
8
|
+
type CursorBubbleKind,
|
|
9
|
+
type CursorThreadHead,
|
|
10
|
+
type CursorThreadSummary,
|
|
11
|
+
type CursorThreadTranscript,
|
|
12
|
+
type CursorToolCall,
|
|
13
|
+
type CursorWorkspaceBucket,
|
|
14
|
+
type CursorWorkspaceGroup,
|
|
15
|
+
type CursorWorkspaceKind,
|
|
16
|
+
getCursorGlobalDbPath,
|
|
17
|
+
getCursorProjectsDir,
|
|
18
|
+
getCursorWorkspaceStorageDir,
|
|
19
|
+
resolveCursorUserDir,
|
|
20
|
+
} from './cursor-exporter-types';
|
|
21
|
+
import { asNumber, asObject, asString, type JsonValue } from './shared';
|
|
22
|
+
|
|
23
|
+
type ComposerEntry = Record<string, JsonValue> & {
|
|
24
|
+
composerId?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
workspaceIdentifier?: { id?: string } | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const CURSOR_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
|
|
30
|
+
|
|
31
|
+
// Cursor databases are WAL-mode. A plain read-only open fails once Cursor cleanly shuts down and
|
|
32
|
+
// removes the -wal/-shm sidecars, and the failure only surfaces at query time (so a try/catch around
|
|
33
|
+
// the constructor never sees it). immutable=1 reads the main database file directly, which works
|
|
34
|
+
// whether or not Cursor is running and whether or not the WAL sidecars are present. The explicit URI
|
|
35
|
+
// flag keeps this portable across SQLite builds where URI filename parsing is not enabled globally.
|
|
36
|
+
export const getCursorReadonlyDbUri = (dbPath: string): string => {
|
|
37
|
+
const normalizedPath = dbPath.replace(/\\/gu, '/');
|
|
38
|
+
const absolutePath = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
39
|
+
const encodedPath = absolutePath
|
|
40
|
+
.split('/')
|
|
41
|
+
.map((segment) => (/^[A-Za-z]:$/u.test(segment) ? segment : encodeURIComponent(segment)))
|
|
42
|
+
.join('/');
|
|
43
|
+
|
|
44
|
+
return `file://${encodedPath}?immutable=1`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const openCursorReadonlyDb = (dbPath: string): Database => {
|
|
48
|
+
return new Database(getCursorReadonlyDbUri(dbPath), CURSOR_READONLY_DB_OPEN_FLAGS);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const pathExists = async (target: string): Promise<boolean> => {
|
|
52
|
+
try {
|
|
53
|
+
await stat(target);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const isMissingOrUnreadableCursorStoreError = (error: unknown): boolean => {
|
|
61
|
+
const code = (error as { code?: unknown }).code;
|
|
62
|
+
return code === 'ENOENT' || code === 'ENOTDIR' || code === 'SQLITE_CANTOPEN';
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const readItemValue = <T>(db: Database, key: string): T | null => {
|
|
66
|
+
const row = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as { value?: string } | null;
|
|
67
|
+
if (!row?.value) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(row.value) as T;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const readKvValue = <T>(db: Database, key: string): T | null => {
|
|
79
|
+
const row = db.query('SELECT value FROM cursorDiskKV WHERE key = ?').get(key) as { value?: string } | null;
|
|
80
|
+
if (!row?.value) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(row.value) as T;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const decodeCursorUri = (uri: string): string => {
|
|
92
|
+
if (!uri) {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (uri.startsWith('file://')) {
|
|
97
|
+
return decodeURIComponent(uri.slice('file://'.length));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return uri;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const normalizeCursorPath = (value: string): string => {
|
|
104
|
+
const decoded = decodeCursorUri(value.trim());
|
|
105
|
+
if (!decoded) {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return decoded.replace(/\/+$/u, '') || decoded;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const parseCodeWorkspaceFolders = async (workspaceFilePath: string): Promise<string[]> => {
|
|
113
|
+
if (!workspaceFilePath.endsWith('.code-workspace')) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const data = (await Bun.file(workspaceFilePath).json()) as { folders?: Array<{ path?: string }> };
|
|
119
|
+
const folders: string[] = [];
|
|
120
|
+
for (const entry of data.folders ?? []) {
|
|
121
|
+
const folderPath = entry.path;
|
|
122
|
+
if (!folderPath) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
folders.push(
|
|
127
|
+
folderPath.startsWith('/')
|
|
128
|
+
? normalizeCursorPath(folderPath)
|
|
129
|
+
: normalizeCursorPath(path.join(path.dirname(workspaceFilePath), folderPath)),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return folders;
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const loadGlobalComposerHeaders = (globalDbPath: string): ComposerEntry[] => {
|
|
140
|
+
const db = openCursorReadonlyDb(globalDbPath);
|
|
141
|
+
try {
|
|
142
|
+
const data = readItemValue<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY);
|
|
143
|
+
return data?.allComposers ?? [];
|
|
144
|
+
} finally {
|
|
145
|
+
db.close();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const readBucketWorkspaceJson = async (
|
|
150
|
+
workspaceJsonPath: string,
|
|
151
|
+
): Promise<{ folder?: string; workspace?: string } | null> => {
|
|
152
|
+
try {
|
|
153
|
+
return (await Bun.file(workspaceJsonPath).json()) as { folder?: string; workspace?: string };
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const resolveBucketIdentity = async (
|
|
160
|
+
wsData: { folder?: string; workspace?: string },
|
|
161
|
+
bucketId: string,
|
|
162
|
+
): Promise<{ kind: CursorWorkspaceKind; uri: string; label: string; folders: string[] }> => {
|
|
163
|
+
if (wsData.folder) {
|
|
164
|
+
const folder = normalizeCursorPath(wsData.folder);
|
|
165
|
+
return {
|
|
166
|
+
folders: folder ? [folder] : [],
|
|
167
|
+
kind: 'folder',
|
|
168
|
+
label: folder ? path.basename(folder) : bucketId,
|
|
169
|
+
uri: wsData.folder,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (wsData.workspace) {
|
|
174
|
+
const workspacePath = normalizeCursorPath(wsData.workspace);
|
|
175
|
+
return {
|
|
176
|
+
folders: workspacePath ? await parseCodeWorkspaceFolders(workspacePath) : [],
|
|
177
|
+
kind: 'workspace',
|
|
178
|
+
label: workspacePath ? path.basename(workspacePath) : bucketId,
|
|
179
|
+
uri: wsData.workspace,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { folders: [], kind: 'unknown', label: bucketId, uri: '' };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const readBucketComposerIds = (dbPath: string): string[] => {
|
|
187
|
+
try {
|
|
188
|
+
const db = openCursorReadonlyDb(dbPath);
|
|
189
|
+
try {
|
|
190
|
+
const data = readItemValue<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_DATA_KEY);
|
|
191
|
+
return (data?.allComposers ?? [])
|
|
192
|
+
.map((entry) => entry.composerId)
|
|
193
|
+
.filter((value): value is string => Boolean(value));
|
|
194
|
+
} finally {
|
|
195
|
+
db.close();
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const loadCursorBuckets = async (userDir = resolveCursorUserDir()): Promise<CursorWorkspaceBucket[]> => {
|
|
203
|
+
const workspaceStorageDir = getCursorWorkspaceStorageDir(userDir);
|
|
204
|
+
let bucketIds: string[] = [];
|
|
205
|
+
try {
|
|
206
|
+
bucketIds = await readdir(workspaceStorageDir);
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const globalDbPath = getCursorGlobalDbPath(userDir);
|
|
212
|
+
const headerIdsByBucket = new Map<string, Set<string>>();
|
|
213
|
+
if (await pathExists(globalDbPath)) {
|
|
214
|
+
for (const header of loadGlobalComposerHeaders(globalDbPath)) {
|
|
215
|
+
const id = header.workspaceIdentifier?.id;
|
|
216
|
+
if (id && header.composerId) {
|
|
217
|
+
const set = headerIdsByBucket.get(id) ?? new Set<string>();
|
|
218
|
+
set.add(header.composerId);
|
|
219
|
+
headerIdsByBucket.set(id, set);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const buckets: CursorWorkspaceBucket[] = [];
|
|
225
|
+
for (const bucketId of bucketIds) {
|
|
226
|
+
const bucket = await buildBucket(workspaceStorageDir, bucketId, headerIdsByBucket);
|
|
227
|
+
if (bucket) {
|
|
228
|
+
buckets.push(bucket);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return buckets;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const buildBucket = async (
|
|
236
|
+
workspaceStorageDir: string,
|
|
237
|
+
bucketId: string,
|
|
238
|
+
headerIdsByBucket: Map<string, Set<string>>,
|
|
239
|
+
): Promise<CursorWorkspaceBucket | null> => {
|
|
240
|
+
const root = path.join(workspaceStorageDir, bucketId);
|
|
241
|
+
const workspaceJsonPath = path.join(root, 'workspace.json');
|
|
242
|
+
const dbPath = path.join(root, 'state.vscdb');
|
|
243
|
+
if (!(await pathExists(workspaceJsonPath)) || !(await pathExists(dbPath))) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const wsData = await readBucketWorkspaceJson(workspaceJsonPath);
|
|
248
|
+
if (!wsData || (!wsData.folder && !wsData.workspace)) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let identity: Awaited<ReturnType<typeof resolveBucketIdentity>>;
|
|
253
|
+
let dbStat: Awaited<ReturnType<typeof stat>>;
|
|
254
|
+
let composerIds: string[];
|
|
255
|
+
try {
|
|
256
|
+
identity = await resolveBucketIdentity(wsData, bucketId);
|
|
257
|
+
dbStat = await stat(dbPath);
|
|
258
|
+
composerIds = readBucketComposerIds(dbPath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (isMissingOrUnreadableCursorStoreError(error)) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const headerIds = headerIdsByBucket.get(bucketId) ?? new Set<string>();
|
|
268
|
+
const threadComposerIds = [...new Set([...composerIds, ...headerIds])];
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
bucketId,
|
|
272
|
+
composerCount: composerIds.length,
|
|
273
|
+
dbPath,
|
|
274
|
+
dbSizeBytes: dbStat.size,
|
|
275
|
+
folders: identity.folders,
|
|
276
|
+
globalHeaderCount: headerIds.size,
|
|
277
|
+
kind: identity.kind,
|
|
278
|
+
label: identity.label,
|
|
279
|
+
mtimeMs: dbStat.mtimeMs,
|
|
280
|
+
threadComposerIds,
|
|
281
|
+
uri: identity.uri,
|
|
282
|
+
workspaceJsonPath,
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const getCursorWorkspaceGroupKey = (bucket: CursorWorkspaceBucket): string => {
|
|
287
|
+
if (bucket.kind === 'folder' && bucket.folders[0]) {
|
|
288
|
+
return `folder:${bucket.folders[0]}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (bucket.kind === 'workspace') {
|
|
292
|
+
return `workspace:${normalizeCursorPath(bucket.uri)}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return `unknown:${bucket.bucketId}`;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export const groupCursorBuckets = (buckets: CursorWorkspaceBucket[]): CursorWorkspaceGroup[] => {
|
|
299
|
+
const grouped = new Map<string, CursorWorkspaceBucket[]>();
|
|
300
|
+
for (const bucket of buckets) {
|
|
301
|
+
const key = getCursorWorkspaceGroupKey(bucket);
|
|
302
|
+
const list = grouped.get(key) ?? [];
|
|
303
|
+
list.push(bucket);
|
|
304
|
+
grouped.set(key, list);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const groups: CursorWorkspaceGroup[] = [];
|
|
308
|
+
for (const [key, list] of grouped.entries()) {
|
|
309
|
+
const ranked = [...list].sort((a, b) => b.mtimeMs - a.mtimeMs || b.dbSizeBytes - a.dbSizeBytes);
|
|
310
|
+
const primary = ranked[0]!;
|
|
311
|
+
const newest = ranked[0]!;
|
|
312
|
+
// De-duplicate composer ids across buckets so the same thread isn't counted once per bucket.
|
|
313
|
+
const threadCount = new Set(ranked.flatMap((bucket) => bucket.threadComposerIds)).size;
|
|
314
|
+
const olderWithData = ranked
|
|
315
|
+
.slice(1)
|
|
316
|
+
.some((bucket) => bucket.composerCount > 0 || bucket.globalHeaderCount > 0);
|
|
317
|
+
|
|
318
|
+
groups.push({
|
|
319
|
+
buckets: ranked,
|
|
320
|
+
folders: primary.folders,
|
|
321
|
+
key,
|
|
322
|
+
kind: primary.kind,
|
|
323
|
+
label: primary.label,
|
|
324
|
+
lastActiveMs: Math.max(...ranked.map((bucket) => bucket.mtimeMs)),
|
|
325
|
+
needsRecovery: ranked.length > 1 && olderWithData && newest.composerCount === 0,
|
|
326
|
+
threadCount,
|
|
327
|
+
uri: primary.uri,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return groups.sort((a, b) => b.lastActiveMs - a.lastActiveMs);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const listCursorWorkspaceGroups = async (userDir = resolveCursorUserDir()): Promise<CursorWorkspaceGroup[]> => {
|
|
335
|
+
return (await discoverCursorWorkspaces(userDir)).groups;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export const cursorBucketMatchesQuery = (bucket: CursorWorkspaceBucket, query: string): boolean => {
|
|
339
|
+
const raw = query.trim();
|
|
340
|
+
if (!raw) {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const lowered = raw.toLowerCase();
|
|
345
|
+
const looksLikePath = raw.startsWith('/') || raw.startsWith('~') || raw.includes('/');
|
|
346
|
+
|
|
347
|
+
if (looksLikePath) {
|
|
348
|
+
const normalized = normalizeCursorPath(raw);
|
|
349
|
+
if (bucket.folders.includes(normalized)) {
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const bucketUri = decodeCursorUri(bucket.uri);
|
|
354
|
+
return bucketUri === normalized || bucketUri.endsWith(raw) || bucketUri.endsWith(normalized);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (raw.endsWith('.code-workspace')) {
|
|
358
|
+
return (
|
|
359
|
+
decodeCursorUri(bucket.uri).toLowerCase().endsWith(lowered) ||
|
|
360
|
+
path.basename(decodeCursorUri(bucket.uri)).toLowerCase() === lowered
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (lowered === bucket.bucketId.toLowerCase() || lowered === bucket.label.toLowerCase()) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return bucket.folders.some((folder) => path.basename(folder).toLowerCase() === lowered);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const groupMatchesQuery = (group: CursorWorkspaceGroup, query: string): boolean => {
|
|
372
|
+
if (group.buckets.some((bucket) => cursorBucketMatchesQuery(bucket, query))) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Bucket-less groups (workspaces whose storage was pruned, or inferred from thread tool paths)
|
|
377
|
+
// still need to match by folder path, basename, or group key.
|
|
378
|
+
const raw = query.trim();
|
|
379
|
+
if (!raw) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const lowered = raw.toLowerCase();
|
|
384
|
+
if (group.key.toLowerCase() === lowered || group.label.toLowerCase() === lowered) {
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (raw.startsWith('/') || raw.includes('/')) {
|
|
389
|
+
const normalized = normalizeCursorPath(raw);
|
|
390
|
+
return group.folders.some((folder) => folder === normalized || folder.endsWith(normalized));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return group.folders.some((folder) => path.basename(folder).toLowerCase() === lowered);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
export const findCursorWorkspaceGroups = (groups: CursorWorkspaceGroup[], query: string): CursorWorkspaceGroup[] => {
|
|
397
|
+
return groups.filter((group) => groupMatchesQuery(group, query));
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const countBubbles = (db: Database, composerId: string): { count: number; bytes: number } => {
|
|
401
|
+
const row = db
|
|
402
|
+
.query('SELECT COUNT(*) AS count, COALESCE(SUM(length(value)), 0) AS bytes FROM cursorDiskKV WHERE key LIKE ?')
|
|
403
|
+
.get(`bubbleId:${composerId}:%`) as { count: number; bytes: number };
|
|
404
|
+
return { bytes: row.bytes, count: row.count };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export const findCursorTranscriptDirs = async (
|
|
408
|
+
composerId: string,
|
|
409
|
+
userDir = resolveCursorUserDir(),
|
|
410
|
+
): Promise<string[]> => {
|
|
411
|
+
const projectsDir = getCursorProjectsDir(userDir);
|
|
412
|
+
if (!(await pathExists(projectsDir))) {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const matches: string[] = [];
|
|
417
|
+
let projectDirs: string[] = [];
|
|
418
|
+
try {
|
|
419
|
+
projectDirs = await readdir(projectsDir);
|
|
420
|
+
} catch {
|
|
421
|
+
return [];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for (const projectDir of projectDirs) {
|
|
425
|
+
const transcriptDir = path.join(projectsDir, projectDir, 'agent-transcripts', composerId);
|
|
426
|
+
if (await pathExists(transcriptDir)) {
|
|
427
|
+
matches.push(transcriptDir);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return matches;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
export type ListCursorThreadsOptions = {
|
|
435
|
+
includeTranscriptDirs?: boolean;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
export const listCursorThreadsForGroup = async (
|
|
439
|
+
group: CursorWorkspaceGroup,
|
|
440
|
+
userDir = resolveCursorUserDir(),
|
|
441
|
+
options: ListCursorThreadsOptions = {},
|
|
442
|
+
): Promise<CursorThreadSummary[]> => {
|
|
443
|
+
const discovery = await discoverCursorWorkspaces(userDir);
|
|
444
|
+
const threads = discovery.threadsByKey.get(group.key) ?? [];
|
|
445
|
+
|
|
446
|
+
if (options.includeTranscriptDirs === false) {
|
|
447
|
+
return threads;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return Promise.all(
|
|
451
|
+
threads.map(async (thread) => ({
|
|
452
|
+
...thread,
|
|
453
|
+
transcriptDirs: await findCursorTranscriptDirs(thread.composerId, userDir),
|
|
454
|
+
})),
|
|
455
|
+
);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// Older threads' workspace buckets get pruned by Cursor over time, and many threads predate the
|
|
459
|
+
// workspace-linking migration, so a bucket-only walk hides large amounts of history. Discovery
|
|
460
|
+
// instead enumerates every thread in the global store and resolves each to a folder via (in order):
|
|
461
|
+
// its global header workspace uri, an existing bucket it points at, or — for threads with no such
|
|
462
|
+
// link — the dominant absolute path found in its tool calls.
|
|
463
|
+
|
|
464
|
+
type GlobalHead = {
|
|
465
|
+
name: string | null;
|
|
466
|
+
createdAtMs: number | null;
|
|
467
|
+
lastUpdatedAtMs: number | null;
|
|
468
|
+
mode: string | null;
|
|
469
|
+
pathHint: string | null;
|
|
470
|
+
};
|
|
471
|
+
type HeaderInfo = { name: string | null; uriPath: string | null; bucketId: string | null };
|
|
472
|
+
type BubbleStat = { count: number; bytes: number };
|
|
473
|
+
|
|
474
|
+
type CursorDiscovery = {
|
|
475
|
+
groups: CursorWorkspaceGroup[];
|
|
476
|
+
threadsByKey: Map<string, CursorThreadSummary[]>;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Discovery does a full scan of the (potentially multi-GB) global DB, so cache it briefly. Writes
|
|
480
|
+
// (recover/prune/delete) call invalidateCursorDiscoveryCache() so the UI never shows stale results.
|
|
481
|
+
const DISCOVERY_TTL_MS = 60_000;
|
|
482
|
+
const UNKNOWN_GROUP_KEY = 'unknown';
|
|
483
|
+
let discoveryCache: { userDir: string; at: number; value: CursorDiscovery } | null = null;
|
|
484
|
+
|
|
485
|
+
export const invalidateCursorDiscoveryCache = (): void => {
|
|
486
|
+
discoveryCache = null;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const DEV_CONTAINER_DIRS = [
|
|
490
|
+
'workspace',
|
|
491
|
+
'projects',
|
|
492
|
+
'dev',
|
|
493
|
+
'code',
|
|
494
|
+
'repos',
|
|
495
|
+
'src',
|
|
496
|
+
'Documents',
|
|
497
|
+
'Downloads',
|
|
498
|
+
'Desktop',
|
|
499
|
+
];
|
|
500
|
+
const REVERSE_WORKSPACE_ROOT_RE = /^\/Users\/[^/]+\/workspace\/reverse\/[^/]+/u;
|
|
501
|
+
const CONTAINER_ROOT_RE = new RegExp(`^(/Users/[^/]+/(?:${DEV_CONTAINER_DIRS.join('|')})/[^/]+)`);
|
|
502
|
+
const ABS_PATH_RE = /\/Users\/[^"'\s:,)\]]+/g;
|
|
503
|
+
|
|
504
|
+
const isNoisePath = (value: string): boolean =>
|
|
505
|
+
/\/Library(?:\/|$)|\/\.cursor(?:\/|$)|\/node_modules\/|\/\.git\/|^\/tmp|^\/var|^\/private|\/\.Trash\//u.test(
|
|
506
|
+
value,
|
|
507
|
+
) || /^\/Users\/[^/]+\/(?:Downloads|Desktop)$/u.test(value);
|
|
508
|
+
|
|
509
|
+
const stripLikelyFileName = (value: string): string => {
|
|
510
|
+
const basename = path.basename(value);
|
|
511
|
+
return basename.includes('.') ? path.dirname(value) : value;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const containerRootFromPath = (value: string): string | null => {
|
|
515
|
+
const candidate = stripLikelyFileName(normalizeCursorPath(value));
|
|
516
|
+
const reverseMatch = candidate.match(REVERSE_WORKSPACE_ROOT_RE);
|
|
517
|
+
if (reverseMatch) {
|
|
518
|
+
return reverseMatch[0] ?? null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const match = candidate.match(CONTAINER_ROOT_RE);
|
|
522
|
+
if (match) {
|
|
523
|
+
return match[1] ?? null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const parts = candidate.split('/');
|
|
527
|
+
if (parts.length >= 4 && parts[1] === 'Users') {
|
|
528
|
+
return `/${parts.slice(1, 4).join('/')}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return null;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const inferFolderFromPaths = (paths: string[]): string | null => {
|
|
535
|
+
const counts = new Map<string, number>();
|
|
536
|
+
for (const value of paths) {
|
|
537
|
+
if (isNoisePath(value)) {
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const root = containerRootFromPath(value);
|
|
542
|
+
if (root) {
|
|
543
|
+
counts.set(root, (counts.get(root) ?? 0) + 1);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let best: string | null = null;
|
|
548
|
+
let bestCount = 0;
|
|
549
|
+
for (const [root, count] of counts) {
|
|
550
|
+
if (count > bestCount) {
|
|
551
|
+
best = root;
|
|
552
|
+
bestCount = count;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return best;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const inferFolderFromBlob = (blob: string): string | null => {
|
|
560
|
+
const matches = blob.match(ABS_PATH_RE);
|
|
561
|
+
return matches ? inferFolderFromPaths(matches) : null;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const readCursorFileHistoryProjectActivity = async (userDir: string): Promise<Map<string, number>> => {
|
|
565
|
+
const historyDir = path.join(userDir, 'History');
|
|
566
|
+
let entries: Array<{ isDirectory: () => boolean; name: string }> = [];
|
|
567
|
+
try {
|
|
568
|
+
entries = await readdir(historyDir, { withFileTypes: true });
|
|
569
|
+
} catch {
|
|
570
|
+
return new Map();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const activity = new Map<string, number>();
|
|
574
|
+
for (const entry of entries) {
|
|
575
|
+
if (!entry.isDirectory()) {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const entriesPath = path.join(historyDir, entry.name, 'entries.json');
|
|
580
|
+
let data: { resource?: string; entries?: Array<{ timestamp?: number }> };
|
|
581
|
+
try {
|
|
582
|
+
data = (await Bun.file(entriesPath).json()) as {
|
|
583
|
+
resource?: string;
|
|
584
|
+
entries?: Array<{ timestamp?: number }>;
|
|
585
|
+
};
|
|
586
|
+
} catch {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const resource = typeof data.resource === 'string' ? data.resource : '';
|
|
591
|
+
const folder = containerRootFromPath(resource);
|
|
592
|
+
if (!folder || isNoisePath(folder)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const lastActiveMs = Math.max(0, ...(data.entries ?? []).map((item) => item.timestamp ?? 0));
|
|
597
|
+
activity.set(folder, Math.max(activity.get(folder) ?? 0, lastActiveMs));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return activity;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const inferFolderFromBubbles = (db: Database, composerId: string): string | null => {
|
|
604
|
+
const rows = db
|
|
605
|
+
.query('SELECT value FROM cursorDiskKV WHERE key LIKE ? LIMIT 80')
|
|
606
|
+
.all(`bubbleId:${composerId}:%`) as Array<{ value: string }>;
|
|
607
|
+
const paths: string[] = [];
|
|
608
|
+
|
|
609
|
+
for (const { value } of rows) {
|
|
610
|
+
let bubble: Record<string, JsonValue>;
|
|
611
|
+
try {
|
|
612
|
+
bubble = JSON.parse(value) as Record<string, JsonValue>;
|
|
613
|
+
} catch {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const tool = asObject(bubble.toolFormerData ?? null);
|
|
618
|
+
if (!tool) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const blob = `${asString(tool.rawArgs ?? null) ?? ''} ${asString(tool.params ?? null) ?? ''}`;
|
|
623
|
+
const matches = blob.match(ABS_PATH_RE);
|
|
624
|
+
if (matches) {
|
|
625
|
+
paths.push(...matches);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (paths.length > 200) {
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return inferFolderFromPaths(paths);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const readAllHeads = (db: Database): Map<string, GlobalHead> => {
|
|
637
|
+
const rows = db
|
|
638
|
+
.query(`SELECT substr(key, 14) AS id, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'`)
|
|
639
|
+
.all() as Array<{ id: string; value: string }>;
|
|
640
|
+
|
|
641
|
+
return new Map(rows.map((row) => [row.id, parseGlobalHead(row.value)]));
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const parseGlobalHead = (value: string): GlobalHead => {
|
|
645
|
+
let parsed: Record<string, JsonValue> = {};
|
|
646
|
+
try {
|
|
647
|
+
parsed = JSON.parse(value) as Record<string, JsonValue>;
|
|
648
|
+
} catch {
|
|
649
|
+
return {
|
|
650
|
+
createdAtMs: null,
|
|
651
|
+
lastUpdatedAtMs: null,
|
|
652
|
+
mode: null,
|
|
653
|
+
name: null,
|
|
654
|
+
pathHint: inferFolderFromBlob(value),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
createdAtMs: asNumber(parsed.createdAt ?? null),
|
|
660
|
+
lastUpdatedAtMs: asNumber(parsed.lastUpdatedAt ?? null),
|
|
661
|
+
mode: asString(parsed.unifiedMode ?? null),
|
|
662
|
+
name: asString(parsed.name ?? null),
|
|
663
|
+
pathHint: inferFolderFromBlob(value),
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const readBubbleStats = (db: Database): Map<string, BubbleStat> => {
|
|
668
|
+
// Keys are `bubbleId:<composerId>:<bubbleId>`; composer ids contain no colon, so slice up to the
|
|
669
|
+
// next ':' rather than assuming a fixed UUID length (keeps tests and any id format working).
|
|
670
|
+
const rows = db
|
|
671
|
+
.query(
|
|
672
|
+
`SELECT substr(key, 10, instr(substr(key, 10), ':') - 1) AS id,
|
|
673
|
+
COUNT(*) AS count,
|
|
674
|
+
COALESCE(SUM(length(value)), 0) AS bytes
|
|
675
|
+
FROM cursorDiskKV WHERE key GLOB 'bubbleId:*:*' GROUP BY id`,
|
|
676
|
+
)
|
|
677
|
+
.all() as Array<{ id: string; count: number; bytes: number }>;
|
|
678
|
+
|
|
679
|
+
return new Map(rows.map((row) => [row.id, { bytes: row.bytes, count: row.count }]));
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const readHeaderInfo = (globalDbPath: string): Map<string, HeaderInfo> => {
|
|
683
|
+
const info = new Map<string, HeaderInfo>();
|
|
684
|
+
for (const header of loadGlobalComposerHeaders(globalDbPath)) {
|
|
685
|
+
if (!header.composerId) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const identifier = header.workspaceIdentifier as
|
|
690
|
+
| { id?: string; uri?: { path?: string; fsPath?: string } }
|
|
691
|
+
| undefined;
|
|
692
|
+
const uriPath = identifier?.uri?.path ?? identifier?.uri?.fsPath ?? null;
|
|
693
|
+
info.set(header.composerId, {
|
|
694
|
+
bucketId: identifier?.id ?? null,
|
|
695
|
+
name: typeof header.name === 'string' ? header.name : null,
|
|
696
|
+
uriPath: uriPath ? normalizeCursorPath(uriPath) : null,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return info;
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const collectBucketComposerIds = (buckets: CursorWorkspaceBucket[]): Map<string, string> => {
|
|
704
|
+
const map = new Map<string, string>();
|
|
705
|
+
for (const bucket of buckets) {
|
|
706
|
+
for (const composerId of readBucketComposerIds(bucket.dbPath)) {
|
|
707
|
+
if (!map.has(composerId)) {
|
|
708
|
+
map.set(composerId, bucket.bucketId);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return map;
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
type ResolvedThread = {
|
|
717
|
+
composerId: string;
|
|
718
|
+
name: string;
|
|
719
|
+
createdAtMs: number | null;
|
|
720
|
+
lastUpdatedAtMs: number | null;
|
|
721
|
+
mode: string | null;
|
|
722
|
+
stat: BubbleStat;
|
|
723
|
+
folder: string | null;
|
|
724
|
+
groupKey: string;
|
|
725
|
+
groupLabel: string;
|
|
726
|
+
bucketId: string | null;
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const findLinkedBucketId = (
|
|
730
|
+
composerId: string,
|
|
731
|
+
headerInfo: HeaderInfo | undefined,
|
|
732
|
+
bucketIdToGroupKey: Map<string, string>,
|
|
733
|
+
bucketComposerIds: Map<string, string>,
|
|
734
|
+
): string | null => {
|
|
735
|
+
if (headerInfo?.bucketId && bucketIdToGroupKey.has(headerInfo.bucketId)) {
|
|
736
|
+
return headerInfo.bucketId;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return bucketComposerIds.get(composerId) ?? null;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const resolveThreadFolderHint = (
|
|
743
|
+
composerId: string,
|
|
744
|
+
head: GlobalHead | undefined,
|
|
745
|
+
headerInfo: HeaderInfo | undefined,
|
|
746
|
+
stat: BubbleStat,
|
|
747
|
+
linkedBucketId: string | null,
|
|
748
|
+
bucketIdToGroupKey: Map<string, string>,
|
|
749
|
+
bucketIdToFolder: Map<string, string | null>,
|
|
750
|
+
db: Database,
|
|
751
|
+
): { folder: string | null; groupKey: string } => {
|
|
752
|
+
if (linkedBucketId && bucketIdToGroupKey.has(linkedBucketId)) {
|
|
753
|
+
return {
|
|
754
|
+
folder: bucketIdToFolder.get(linkedBucketId) ?? null,
|
|
755
|
+
groupKey: bucketIdToGroupKey.get(linkedBucketId)!,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (headerInfo?.uriPath) {
|
|
760
|
+
return { folder: headerInfo.uriPath, groupKey: `folder:${headerInfo.uriPath}` };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (head?.pathHint) {
|
|
764
|
+
return { folder: head.pathHint, groupKey: `folder:${head.pathHint}` };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (stat.count > 0) {
|
|
768
|
+
const folder = inferFolderFromBubbles(db, composerId);
|
|
769
|
+
return { folder, groupKey: folder ? `folder:${folder}` : UNKNOWN_GROUP_KEY };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return { folder: null, groupKey: UNKNOWN_GROUP_KEY };
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
const resolveThreadFolder = (
|
|
776
|
+
composerId: string,
|
|
777
|
+
head: GlobalHead | undefined,
|
|
778
|
+
headerInfo: HeaderInfo | undefined,
|
|
779
|
+
stat: BubbleStat,
|
|
780
|
+
bucketIdToGroupKey: Map<string, string>,
|
|
781
|
+
bucketIdToFolder: Map<string, string | null>,
|
|
782
|
+
bucketComposerIds: Map<string, string>,
|
|
783
|
+
db: Database,
|
|
784
|
+
): ResolvedThread => {
|
|
785
|
+
const linkedBucketId = findLinkedBucketId(composerId, headerInfo, bucketIdToGroupKey, bucketComposerIds);
|
|
786
|
+
const { folder, groupKey } = resolveThreadFolderHint(
|
|
787
|
+
composerId,
|
|
788
|
+
head,
|
|
789
|
+
headerInfo,
|
|
790
|
+
stat,
|
|
791
|
+
linkedBucketId,
|
|
792
|
+
bucketIdToGroupKey,
|
|
793
|
+
bucketIdToFolder,
|
|
794
|
+
db,
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
bucketId: linkedBucketId,
|
|
799
|
+
composerId,
|
|
800
|
+
createdAtMs: head?.createdAtMs ?? null,
|
|
801
|
+
folder,
|
|
802
|
+
groupKey,
|
|
803
|
+
groupLabel: folder ? path.basename(folder) : 'Unknown project',
|
|
804
|
+
lastUpdatedAtMs: head?.lastUpdatedAtMs ?? null,
|
|
805
|
+
mode: head?.mode ?? null,
|
|
806
|
+
name: head?.name || headerInfo?.name || '(untitled)',
|
|
807
|
+
stat,
|
|
808
|
+
};
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const toThreadSummary = (resolved: ResolvedThread): CursorThreadSummary => ({
|
|
812
|
+
bubbleBytes: resolved.stat.bytes,
|
|
813
|
+
bubbleCount: resolved.stat.count,
|
|
814
|
+
bucketId: resolved.bucketId,
|
|
815
|
+
composerId: resolved.composerId,
|
|
816
|
+
createdAtMs: resolved.createdAtMs,
|
|
817
|
+
lastUpdatedAtMs: resolved.lastUpdatedAtMs,
|
|
818
|
+
mode: resolved.mode,
|
|
819
|
+
name: resolved.name,
|
|
820
|
+
transcriptDirs: [],
|
|
821
|
+
workspaceKey: resolved.groupKey,
|
|
822
|
+
workspaceLabel: resolved.groupLabel,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const assembleDiscovery = (
|
|
826
|
+
resolved: ResolvedThread[],
|
|
827
|
+
bucketGroups: CursorWorkspaceGroup[],
|
|
828
|
+
fileHistoryActivity: Map<string, number>,
|
|
829
|
+
): CursorDiscovery => {
|
|
830
|
+
const threadsByKey = new Map<string, CursorThreadSummary[]>();
|
|
831
|
+
const lastActiveByKey = new Map<string, number>();
|
|
832
|
+
|
|
833
|
+
for (const thread of resolved) {
|
|
834
|
+
// Empty threads with no resolvable workspace are pure noise; keep them out of the catch-all.
|
|
835
|
+
if (thread.groupKey === UNKNOWN_GROUP_KEY && thread.stat.count === 0) {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const list = threadsByKey.get(thread.groupKey) ?? [];
|
|
840
|
+
list.push(toThreadSummary(thread));
|
|
841
|
+
threadsByKey.set(thread.groupKey, list);
|
|
842
|
+
lastActiveByKey.set(
|
|
843
|
+
thread.groupKey,
|
|
844
|
+
Math.max(lastActiveByKey.get(thread.groupKey) ?? 0, thread.lastUpdatedAtMs ?? 0),
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
for (const [folder, lastActiveMs] of fileHistoryActivity) {
|
|
849
|
+
const key = `folder:${folder}`;
|
|
850
|
+
lastActiveByKey.set(key, Math.max(lastActiveByKey.get(key) ?? 0, lastActiveMs));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
for (const list of threadsByKey.values()) {
|
|
854
|
+
list.sort((a, b) => (b.lastUpdatedAtMs ?? 0) - (a.lastUpdatedAtMs ?? 0));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const groups = buildDiscoveryGroups(threadsByKey, bucketGroups, lastActiveByKey);
|
|
858
|
+
return { groups, threadsByKey };
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
const mergeBucketGroup = (
|
|
862
|
+
bucketGroup: CursorWorkspaceGroup,
|
|
863
|
+
threadsByKey: Map<string, CursorThreadSummary[]>,
|
|
864
|
+
lastActiveByKey: Map<string, number>,
|
|
865
|
+
): CursorWorkspaceGroup => {
|
|
866
|
+
const threads = threadsByKey.get(bucketGroup.key) ?? [];
|
|
867
|
+
return {
|
|
868
|
+
...bucketGroup,
|
|
869
|
+
lastActiveMs: Math.max(bucketGroup.lastActiveMs, lastActiveByKey.get(bucketGroup.key) ?? 0),
|
|
870
|
+
threadCount: threads.length || bucketGroup.threadCount,
|
|
871
|
+
};
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
const buildBucketlessGroup = (key: string, threadCount: number, lastActiveMs: number): CursorWorkspaceGroup => {
|
|
875
|
+
const isUnknown = key === UNKNOWN_GROUP_KEY;
|
|
876
|
+
const folder = isUnknown ? '' : key.slice('folder:'.length);
|
|
877
|
+
return {
|
|
878
|
+
buckets: [],
|
|
879
|
+
folders: folder ? [folder] : [],
|
|
880
|
+
key,
|
|
881
|
+
kind: isUnknown ? 'unknown' : 'folder',
|
|
882
|
+
label: isUnknown ? 'Unknown project' : path.basename(folder),
|
|
883
|
+
lastActiveMs,
|
|
884
|
+
needsRecovery: false,
|
|
885
|
+
threadCount,
|
|
886
|
+
uri: folder ? `file://${folder}` : '',
|
|
887
|
+
};
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const buildDiscoveryGroups = (
|
|
891
|
+
threadsByKey: Map<string, CursorThreadSummary[]>,
|
|
892
|
+
bucketGroups: CursorWorkspaceGroup[],
|
|
893
|
+
lastActiveByKey: Map<string, number>,
|
|
894
|
+
): CursorWorkspaceGroup[] => {
|
|
895
|
+
const seen = new Set(bucketGroups.map((group) => group.key));
|
|
896
|
+
const groups = bucketGroups.map((group) => mergeBucketGroup(group, threadsByKey, lastActiveByKey));
|
|
897
|
+
|
|
898
|
+
const keys = new Set([...threadsByKey.keys(), ...lastActiveByKey.keys()]);
|
|
899
|
+
for (const key of keys) {
|
|
900
|
+
const threads = threadsByKey.get(key) ?? [];
|
|
901
|
+
if (!seen.has(key) && (threads.length > 0 || key !== UNKNOWN_GROUP_KEY)) {
|
|
902
|
+
groups.push(buildBucketlessGroup(key, threads.length, lastActiveByKey.get(key) ?? 0));
|
|
903
|
+
seen.add(key);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return groups.sort((a, b) => b.lastActiveMs - a.lastActiveMs);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const buildDiscovery = async (userDir: string): Promise<CursorDiscovery> => {
|
|
911
|
+
const buckets = await loadCursorBuckets(userDir);
|
|
912
|
+
const bucketGroups = groupCursorBuckets(buckets);
|
|
913
|
+
const fileHistoryActivity = await readCursorFileHistoryProjectActivity(userDir);
|
|
914
|
+
const globalDbPath = getCursorGlobalDbPath(userDir);
|
|
915
|
+
|
|
916
|
+
if (!(await pathExists(globalDbPath))) {
|
|
917
|
+
return assembleDiscovery([], bucketGroups, fileHistoryActivity);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const bucketIdToGroupKey = new Map<string, string>();
|
|
921
|
+
const bucketIdToFolder = new Map<string, string | null>();
|
|
922
|
+
for (const group of bucketGroups) {
|
|
923
|
+
for (const bucket of group.buckets) {
|
|
924
|
+
bucketIdToGroupKey.set(bucket.bucketId, group.key);
|
|
925
|
+
bucketIdToFolder.set(bucket.bucketId, group.folders[0] ?? null);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const headerInfo = readHeaderInfo(globalDbPath);
|
|
930
|
+
const bucketComposerIds = collectBucketComposerIds(buckets);
|
|
931
|
+
|
|
932
|
+
const db = openCursorReadonlyDb(globalDbPath);
|
|
933
|
+
try {
|
|
934
|
+
const heads = readAllHeads(db);
|
|
935
|
+
const stats = readBubbleStats(db);
|
|
936
|
+
const universe = new Set<string>([...heads.keys(), ...headerInfo.keys(), ...bucketComposerIds.keys()]);
|
|
937
|
+
const resolved: ResolvedThread[] = [];
|
|
938
|
+
|
|
939
|
+
for (const composerId of universe) {
|
|
940
|
+
resolved.push(
|
|
941
|
+
resolveThreadFolder(
|
|
942
|
+
composerId,
|
|
943
|
+
heads.get(composerId),
|
|
944
|
+
headerInfo.get(composerId),
|
|
945
|
+
stats.get(composerId) ?? { bytes: 0, count: 0 },
|
|
946
|
+
bucketIdToGroupKey,
|
|
947
|
+
bucketIdToFolder,
|
|
948
|
+
bucketComposerIds,
|
|
949
|
+
db,
|
|
950
|
+
),
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return assembleDiscovery(resolved, bucketGroups, fileHistoryActivity);
|
|
955
|
+
} finally {
|
|
956
|
+
db.close();
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const discoverCursorWorkspaces = async (userDir: string): Promise<CursorDiscovery> => {
|
|
961
|
+
const now = Date.now();
|
|
962
|
+
if (discoveryCache && discoveryCache.userDir === userDir && now - discoveryCache.at < DISCOVERY_TTL_MS) {
|
|
963
|
+
return discoveryCache.value;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const value = await buildDiscovery(userDir);
|
|
967
|
+
discoveryCache = { at: now, userDir, value };
|
|
968
|
+
return value;
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
export const readCursorThreadHead = (globalDbPath: string, composerId: string): CursorThreadHead | null => {
|
|
972
|
+
const db = openCursorReadonlyDb(globalDbPath);
|
|
973
|
+
try {
|
|
974
|
+
const head = readKvValue<Record<string, JsonValue>>(db, `composerData:${composerId}`);
|
|
975
|
+
if (!head) {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const headerList = Array.isArray(head.fullConversationHeadersOnly)
|
|
980
|
+
? (head.fullConversationHeadersOnly as JsonValue[])
|
|
981
|
+
: [];
|
|
982
|
+
const orderedBubbleIds = headerList
|
|
983
|
+
.map((item) => asString(asObject(item)?.bubbleId ?? null))
|
|
984
|
+
.filter((value): value is string => Boolean(value));
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
composerId,
|
|
988
|
+
createdAtMs: asNumber(head.createdAt ?? null),
|
|
989
|
+
lastUpdatedAtMs: asNumber(head.lastUpdatedAt ?? null),
|
|
990
|
+
mode: asString(head.unifiedMode ?? null),
|
|
991
|
+
name: asString(head.name ?? null),
|
|
992
|
+
orderedBubbleIds,
|
|
993
|
+
totalBubbleHeaders: headerList.length,
|
|
994
|
+
};
|
|
995
|
+
} finally {
|
|
996
|
+
db.close();
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const toBubbleKind = (rawType: JsonValue): CursorBubbleKind => {
|
|
1001
|
+
if (rawType === 1) {
|
|
1002
|
+
return 'user';
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (rawType === 2) {
|
|
1006
|
+
return 'assistant';
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return 'unknown';
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const parseToolCall = (raw: JsonValue): CursorToolCall | null => {
|
|
1013
|
+
const data = asObject(raw);
|
|
1014
|
+
if (!data) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const name = asString(data.name ?? null);
|
|
1019
|
+
if (!name) {
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return {
|
|
1024
|
+
argumentsText: asString(data.rawArgs ?? null) ?? asString(data.params ?? null),
|
|
1025
|
+
callId: asString(data.toolCallId ?? null),
|
|
1026
|
+
name,
|
|
1027
|
+
resultText: asString(data.result ?? null),
|
|
1028
|
+
status: asString(data.status ?? null),
|
|
1029
|
+
};
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
export const parseCursorBubble = (bubbleId: string, raw: Record<string, JsonValue>): CursorBubble => {
|
|
1033
|
+
const thinking = asObject(raw.thinking ?? null);
|
|
1034
|
+
|
|
1035
|
+
return {
|
|
1036
|
+
bubbleId,
|
|
1037
|
+
createdAtMs: asNumber(raw.createdAt ?? null),
|
|
1038
|
+
kind: toBubbleKind(raw.type ?? null),
|
|
1039
|
+
text: asString(raw.text ?? null) ?? '',
|
|
1040
|
+
thinking: thinking ? asString(thinking.text ?? null) : null,
|
|
1041
|
+
toolCall: parseToolCall(raw.toolFormerData ?? null),
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const readBubble = (db: Database, composerId: string, bubbleId: string): CursorBubble | null => {
|
|
1046
|
+
const raw = readKvValue<Record<string, JsonValue>>(db, `bubbleId:${composerId}:${bubbleId}`);
|
|
1047
|
+
if (!raw) {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return parseCursorBubble(bubbleId, raw);
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const isRenderableBubble = (bubble: CursorBubble): boolean => {
|
|
1055
|
+
return Boolean(bubble.text.trim() || bubble.thinking?.trim() || bubble.toolCall);
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
export const readCursorThreadTranscript = (globalDbPath: string, composerId: string): CursorThreadTranscript | null => {
|
|
1059
|
+
const head = readCursorThreadHead(globalDbPath, composerId);
|
|
1060
|
+
if (!head) {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const db = openCursorReadonlyDb(globalDbPath);
|
|
1065
|
+
try {
|
|
1066
|
+
const orderedIds = head.orderedBubbleIds.length > 0 ? head.orderedBubbleIds : readAllBubbleIds(db, composerId);
|
|
1067
|
+
const bubbles: CursorBubble[] = [];
|
|
1068
|
+
for (const bubbleId of orderedIds) {
|
|
1069
|
+
const bubble = readBubble(db, composerId, bubbleId);
|
|
1070
|
+
if (bubble && isRenderableBubble(bubble)) {
|
|
1071
|
+
bubbles.push(bubble);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Cursor caps very long threads' header index; stored bubbles beyond the index can't be ordered.
|
|
1076
|
+
const totalBubbleRows = countBubbles(db, composerId).count;
|
|
1077
|
+
const omittedBubbleCount = Math.max(totalBubbleRows - orderedIds.length, 0);
|
|
1078
|
+
|
|
1079
|
+
return {
|
|
1080
|
+
bubbles,
|
|
1081
|
+
head,
|
|
1082
|
+
omittedBubbleCount,
|
|
1083
|
+
renderableBubbleCount: bubbles.length,
|
|
1084
|
+
};
|
|
1085
|
+
} finally {
|
|
1086
|
+
db.close();
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
const readAllBubbleIds = (db: Database, composerId: string): string[] => {
|
|
1091
|
+
const prefix = `bubbleId:${composerId}:`;
|
|
1092
|
+
const rows = db.query('SELECT key FROM cursorDiskKV WHERE key LIKE ? ORDER BY key ASC').all(`${prefix}%`) as Array<{
|
|
1093
|
+
key: string;
|
|
1094
|
+
}>;
|
|
1095
|
+
return rows.map((row) => row.key.slice(prefix.length));
|
|
1096
|
+
};
|