spiracha 2.0.0 → 2.2.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 +27 -12
- package/README.md +42 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +36 -11
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +46 -3
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +23 -5
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +79 -74
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +13 -47
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +209 -11
- package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +185 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +16 -0
- package/apps/ui/src/lib/codex-server.ts +134 -52
- package/apps/ui/src/lib/cursor-server.ts +68 -53
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +168 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +97 -7
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +96 -7
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
- package/apps/ui/src/routeTree.gen.ts +341 -234
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
- package/apps/ui/src/routes/opencode.index.tsx +6 -2
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +37 -27
- package/src/client.ts +183 -5
- package/src/lib/antigravity-db.ts +424 -94
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +534 -42
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +18 -27
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +106 -23
- package/src/lib/codex-thread-parser.ts +149 -55
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +87 -79
- package/src/lib/concurrency.ts +56 -3
- package/src/lib/conversation-api.ts +410 -27
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
- package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
- package/src/lib/conversation-data/codex-adapter.ts +108 -73
- package/src/lib/conversation-data/cursor-adapter.ts +77 -29
- package/src/lib/conversation-data/grok-adapter.ts +223 -0
- package/src/lib/conversation-data/index.ts +105 -11
- package/src/lib/conversation-data/kiro-adapter.ts +59 -12
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +67 -17
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +67 -37
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +75 -0
- package/src/lib/cursor-db.ts +68 -28
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +1068 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +61 -0
- package/src/lib/grok-transcript.ts +167 -0
- package/src/lib/kiro-db.ts +187 -43
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +599 -60
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +63 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +87 -0
- package/src/lib/ui-cache.ts +185 -24
- package/src/lib/ui-export-archive.ts +58 -39
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- package/apps/ui/package.json +0 -65
package/src/lib/cursor-db.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { constants, Database } from 'bun:sqlite';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { readdir, stat } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import {
|
|
@@ -19,7 +20,8 @@ import {
|
|
|
19
20
|
getCursorWorkspaceStorageDir,
|
|
20
21
|
resolveCursorUserDir,
|
|
21
22
|
} from './cursor-exporter-types';
|
|
22
|
-
import {
|
|
23
|
+
import { buildCursorBubbleKeyLikePattern, isSafeCursorComposerId } from './cursor-id';
|
|
24
|
+
import { asNumber, asObject, asString, type JsonValue, pathExists, toFileUri } from './shared';
|
|
23
25
|
|
|
24
26
|
type ComposerEntry = Record<string, JsonValue> & {
|
|
25
27
|
composerId?: string;
|
|
@@ -34,7 +36,7 @@ export const CURSOR_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | co
|
|
|
34
36
|
// the constructor never sees it). immutable=1 reads the main database file directly, which works
|
|
35
37
|
// whether or not Cursor is running and whether or not the WAL sidecars are present. The explicit URI
|
|
36
38
|
// flag keeps this portable across SQLite builds where URI filename parsing is not enabled globally.
|
|
37
|
-
export const getCursorReadonlyDbUri = (dbPath: string): string => {
|
|
39
|
+
export const getCursorReadonlyDbUri = (dbPath: string, immutable = true): string => {
|
|
38
40
|
const normalizedPath = dbPath.replace(/\\/gu, '/');
|
|
39
41
|
const absolutePath = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
40
42
|
const encodedPath = absolutePath
|
|
@@ -42,20 +44,12 @@ export const getCursorReadonlyDbUri = (dbPath: string): string => {
|
|
|
42
44
|
.map((segment) => (/^[A-Za-z]:$/u.test(segment) ? segment : encodeURIComponent(segment)))
|
|
43
45
|
.join('/');
|
|
44
46
|
|
|
45
|
-
return `file://${encodedPath}?immutable=1`;
|
|
47
|
+
return `file://${encodedPath}?${immutable ? 'immutable=1' : 'mode=ro'}`;
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
export const openCursorReadonlyDb = (dbPath: string): Database => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const pathExists = async (target: string): Promise<boolean> => {
|
|
53
|
-
try {
|
|
54
|
-
await stat(target);
|
|
55
|
-
return true;
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
51
|
+
const hasWalSidecars = existsSync(`${dbPath}-wal`) || existsSync(`${dbPath}-shm`);
|
|
52
|
+
return new Database(getCursorReadonlyDbUri(dbPath, !hasWalSidecars), CURSOR_READONLY_DB_OPEN_FLAGS);
|
|
59
53
|
};
|
|
60
54
|
|
|
61
55
|
const isMissingOrUnreadableCursorStoreError = (error: unknown): boolean => {
|
|
@@ -95,7 +89,12 @@ export const decodeCursorUri = (uri: string): string => {
|
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
if (uri.startsWith('file://')) {
|
|
98
|
-
|
|
92
|
+
const rawPath = uri.slice('file://'.length);
|
|
93
|
+
try {
|
|
94
|
+
return decodeURIComponent(rawPath);
|
|
95
|
+
} catch {
|
|
96
|
+
return rawPath;
|
|
97
|
+
}
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
return uri;
|
|
@@ -126,6 +125,11 @@ const parseCodeWorkspaceJson = (text: string): { folders?: Array<{ path?: string
|
|
|
126
125
|
}
|
|
127
126
|
};
|
|
128
127
|
|
|
128
|
+
const isMissingCodeWorkspaceFileError = (error: unknown): boolean => {
|
|
129
|
+
const code = (error as { code?: unknown }).code;
|
|
130
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
131
|
+
};
|
|
132
|
+
|
|
129
133
|
const parseCodeWorkspaceFolders = async (workspaceFilePath: string): Promise<string[]> => {
|
|
130
134
|
if (!workspaceFilePath.endsWith('.code-workspace')) {
|
|
131
135
|
return [];
|
|
@@ -149,6 +153,10 @@ const parseCodeWorkspaceFolders = async (workspaceFilePath: string): Promise<str
|
|
|
149
153
|
|
|
150
154
|
return folders;
|
|
151
155
|
} catch (error) {
|
|
156
|
+
if (isMissingCodeWorkspaceFileError(error)) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
152
160
|
warnCursorDataIssue('invalid_code_workspace_json', {
|
|
153
161
|
error: error instanceof Error ? error.message : String(error),
|
|
154
162
|
workspaceFilePath,
|
|
@@ -431,8 +439,10 @@ export const findCursorWorkspaceGroups = (groups: CursorWorkspaceGroup[], query:
|
|
|
431
439
|
|
|
432
440
|
const countBubbles = (db: Database, composerId: string): { count: number; bytes: number } => {
|
|
433
441
|
const row = db
|
|
434
|
-
.query(
|
|
435
|
-
|
|
442
|
+
.query(
|
|
443
|
+
`SELECT COUNT(*) AS count, COALESCE(SUM(length(value)), 0) AS bytes FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`,
|
|
444
|
+
)
|
|
445
|
+
.get(buildCursorBubbleKeyLikePattern(composerId)) as { count: number; bytes: number };
|
|
436
446
|
return { bytes: row.bytes, count: row.count };
|
|
437
447
|
};
|
|
438
448
|
|
|
@@ -440,6 +450,10 @@ export const findCursorTranscriptDirs = async (
|
|
|
440
450
|
composerId: string,
|
|
441
451
|
userDir = resolveCursorUserDir(),
|
|
442
452
|
): Promise<string[]> => {
|
|
453
|
+
if (!isSafeCursorComposerId(composerId)) {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
|
|
443
457
|
const projectsDir = getCursorProjectsDir(userDir);
|
|
444
458
|
if (!(await pathExists(projectsDir))) {
|
|
445
459
|
return [];
|
|
@@ -454,7 +468,13 @@ export const findCursorTranscriptDirs = async (
|
|
|
454
468
|
}
|
|
455
469
|
|
|
456
470
|
for (const projectDir of projectDirs) {
|
|
457
|
-
const
|
|
471
|
+
const agentTranscriptsDir = path.resolve(projectsDir, projectDir, 'agent-transcripts');
|
|
472
|
+
const transcriptDir = path.resolve(agentTranscriptsDir, composerId);
|
|
473
|
+
const relativePath = path.relative(agentTranscriptsDir, transcriptDir);
|
|
474
|
+
if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
458
478
|
if (await pathExists(transcriptDir)) {
|
|
459
479
|
matches.push(transcriptDir);
|
|
460
480
|
}
|
|
@@ -639,8 +659,8 @@ const readCursorFileHistoryProjectActivity = async (userDir: string): Promise<Ma
|
|
|
639
659
|
|
|
640
660
|
const inferFolderFromBubbles = (db: Database, composerId: string): string | null => {
|
|
641
661
|
const rows = db
|
|
642
|
-
.query(
|
|
643
|
-
.all(
|
|
662
|
+
.query(`SELECT value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\' LIMIT 80`)
|
|
663
|
+
.all(buildCursorBubbleKeyLikePattern(composerId)) as Array<{ value: string }>;
|
|
644
664
|
const paths: string[] = [];
|
|
645
665
|
|
|
646
666
|
for (const { value } of rows) {
|
|
@@ -679,22 +699,32 @@ const readAllHeads = (db: Database, options: CursorDiscoveryOptions = {}): Map<s
|
|
|
679
699
|
WHERE key LIKE 'composerData:%'
|
|
680
700
|
AND COALESCE(json_extract(value, '$.lastUpdatedAt'), 0) >= ?`,
|
|
681
701
|
)
|
|
682
|
-
.all(options.updatedAfterMs) as Array<{ id: string; value: string }>;
|
|
702
|
+
.all(options.updatedAfterMs) as Array<{ id: string; value: string | null }>;
|
|
683
703
|
|
|
684
704
|
return new Map(rows.map((row) => [row.id, parseGlobalHead(row.value)]));
|
|
685
705
|
}
|
|
686
706
|
|
|
687
707
|
const rows = db
|
|
688
708
|
.query(`SELECT substr(key, 14) AS id, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'`)
|
|
689
|
-
.all() as Array<{ id: string; value: string }>;
|
|
709
|
+
.all() as Array<{ id: string; value: string | null }>;
|
|
690
710
|
|
|
691
711
|
return new Map(rows.map((row) => [row.id, parseGlobalHead(row.value)]));
|
|
692
712
|
};
|
|
693
713
|
|
|
694
|
-
const parseGlobalHead = (value: string): GlobalHead => {
|
|
695
|
-
let parsed: Record<string, JsonValue> = {};
|
|
714
|
+
const parseGlobalHead = (value: string | null): GlobalHead => {
|
|
715
|
+
let parsed: Record<string, JsonValue> | null = {};
|
|
716
|
+
if (value === null) {
|
|
717
|
+
return {
|
|
718
|
+
createdAtMs: null,
|
|
719
|
+
lastUpdatedAtMs: null,
|
|
720
|
+
mode: null,
|
|
721
|
+
name: null,
|
|
722
|
+
pathHint: null,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
696
726
|
try {
|
|
697
|
-
parsed = JSON.parse(value) as
|
|
727
|
+
parsed = asObject(JSON.parse(value) as JsonValue);
|
|
698
728
|
} catch {
|
|
699
729
|
return {
|
|
700
730
|
createdAtMs: null,
|
|
@@ -705,6 +735,16 @@ const parseGlobalHead = (value: string): GlobalHead => {
|
|
|
705
735
|
};
|
|
706
736
|
}
|
|
707
737
|
|
|
738
|
+
if (!parsed) {
|
|
739
|
+
return {
|
|
740
|
+
createdAtMs: null,
|
|
741
|
+
lastUpdatedAtMs: null,
|
|
742
|
+
mode: null,
|
|
743
|
+
name: null,
|
|
744
|
+
pathHint: inferFolderFromBlob(value),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
708
748
|
return {
|
|
709
749
|
createdAtMs: asNumber(parsed.createdAt ?? null),
|
|
710
750
|
lastUpdatedAtMs: asNumber(parsed.lastUpdatedAt ?? null),
|
|
@@ -944,7 +984,7 @@ const buildBucketlessGroup = (key: string, threadCount: number, lastActiveMs: nu
|
|
|
944
984
|
lastActiveMs,
|
|
945
985
|
needsRecovery: false,
|
|
946
986
|
threadCount,
|
|
947
|
-
uri: folder ?
|
|
987
|
+
uri: folder ? toFileUri(folder) : '',
|
|
948
988
|
};
|
|
949
989
|
};
|
|
950
990
|
|
|
@@ -1446,8 +1486,8 @@ export const readCursorThreadTranscriptWithAgentFiles = async (
|
|
|
1446
1486
|
|
|
1447
1487
|
const readAllBubbleIds = (db: Database, composerId: string): string[] => {
|
|
1448
1488
|
const prefix = `bubbleId:${composerId}:`;
|
|
1449
|
-
const rows = db
|
|
1450
|
-
key
|
|
1451
|
-
|
|
1489
|
+
const rows = db
|
|
1490
|
+
.query(`SELECT key FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\' ORDER BY key ASC`)
|
|
1491
|
+
.all(buildCursorBubbleKeyLikePattern(composerId)) as Array<{ key: string }>;
|
|
1452
1492
|
return rows.map((row) => row.key.slice(prefix.length));
|
|
1453
1493
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const SAFE_CURSOR_COMPOSER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
|
|
2
|
+
|
|
3
|
+
export const isSafeCursorComposerId = (value: string): boolean => {
|
|
4
|
+
return SAFE_CURSOR_COMPOSER_ID_PATTERN.test(value) && !value.includes('..');
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const assertSafeCursorComposerId = (value: string): void => {
|
|
8
|
+
if (!isSafeCursorComposerId(value)) {
|
|
9
|
+
throw new Error(`Invalid Cursor composer id: ${value}`);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const escapeSqlLikeValue = (value: string): string => value.replace(/[\\%_]/gu, '\\$&');
|
|
14
|
+
|
|
15
|
+
export const buildCursorBubbleKeyLikePattern = (composerId: string): string => {
|
|
16
|
+
return `bubbleId:${escapeSqlLikeValue(composerId)}:%`;
|
|
17
|
+
};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readdir, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mapWithConcurrency } from './concurrency';
|
|
3
6
|
import {
|
|
4
7
|
findCursorTranscriptDirs,
|
|
5
8
|
invalidateCursorDiscoveryCache,
|
|
@@ -16,8 +19,10 @@ import {
|
|
|
16
19
|
type CursorWorkspaceBucket,
|
|
17
20
|
type CursorWorkspaceGroup,
|
|
18
21
|
getCursorGlobalDbPath,
|
|
22
|
+
getCursorProjectsDir,
|
|
19
23
|
resolveCursorUserDir,
|
|
20
24
|
} from './cursor-exporter-types';
|
|
25
|
+
import { assertSafeCursorComposerId, buildCursorBubbleKeyLikePattern } from './cursor-id';
|
|
21
26
|
|
|
22
27
|
type ComposerEntry = {
|
|
23
28
|
composerId?: string;
|
|
@@ -49,6 +54,21 @@ export const isCursorRunning = async (): Promise<boolean> => {
|
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
const backupStamp = (): string => new Date().toISOString().replace(/[-:]/gu, '').replace(/\..+/u, '').replace('T', '-');
|
|
57
|
+
const CURSOR_BACKUP_RETENTION_COUNT = 5;
|
|
58
|
+
|
|
59
|
+
const writeRetainedCursorBackup = async (basePath: string, label: string, value: unknown): Promise<string> => {
|
|
60
|
+
const directory = path.dirname(basePath);
|
|
61
|
+
const filePrefix = `${path.basename(basePath)}.${label}.`;
|
|
62
|
+
const backupPath = `${basePath}.${label}.${backupStamp()}.${randomUUID()}.json`;
|
|
63
|
+
await Bun.write(backupPath, JSON.stringify(value));
|
|
64
|
+
const backups = (await readdir(directory))
|
|
65
|
+
.filter((entry) => entry.startsWith(filePrefix) && entry.endsWith('.json'))
|
|
66
|
+
.sort((left, right) => right.localeCompare(left));
|
|
67
|
+
await Promise.all(
|
|
68
|
+
backups.slice(CURSOR_BACKUP_RETENTION_COUNT).map((entry) => rm(path.join(directory, entry), { force: true })),
|
|
69
|
+
);
|
|
70
|
+
return backupPath;
|
|
71
|
+
};
|
|
52
72
|
|
|
53
73
|
// The Cursor global DB can be multiple gigabytes, so copying the whole file per operation is not
|
|
54
74
|
// viable. We instead write small, targeted JSON backups of only the data each operation touches.
|
|
@@ -61,22 +81,20 @@ const backupComposerHeaders = async (globalDbPath: string): Promise<string> => {
|
|
|
61
81
|
db.close();
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
|
|
65
|
-
await Bun.write(backupPath, JSON.stringify(headers));
|
|
66
|
-
return backupPath;
|
|
84
|
+
return writeRetainedCursorBackup(globalDbPath, 'composerHeaders', headers);
|
|
67
85
|
};
|
|
68
86
|
|
|
69
87
|
const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]): Promise<string> => {
|
|
70
88
|
const db = openCursorReadonlyDb(globalDbPath);
|
|
71
89
|
try {
|
|
72
90
|
const dump = composerIds.map((composerId) => ({
|
|
73
|
-
bubbles: db
|
|
91
|
+
bubbles: db
|
|
92
|
+
.query(`SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`)
|
|
93
|
+
.all(buildCursorBubbleKeyLikePattern(composerId)),
|
|
74
94
|
composerData: readJsonItemFromKv(db, `composerData:${composerId}`),
|
|
75
95
|
composerId,
|
|
76
96
|
}));
|
|
77
|
-
|
|
78
|
-
await Bun.write(backupPath, JSON.stringify(dump));
|
|
79
|
-
return backupPath;
|
|
97
|
+
return writeRetainedCursorBackup(globalDbPath, 'prunedThreads', dump);
|
|
80
98
|
} finally {
|
|
81
99
|
db.close();
|
|
82
100
|
}
|
|
@@ -227,8 +245,8 @@ const relinkHeaders = (
|
|
|
227
245
|
|
|
228
246
|
const countBubbles = (db: Database, composerId: string): number => {
|
|
229
247
|
const row = db
|
|
230
|
-
.query(
|
|
231
|
-
.get(
|
|
248
|
+
.query(`SELECT COUNT(*) AS count FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`)
|
|
249
|
+
.get(buildCursorBubbleKeyLikePattern(composerId)) as { count: number };
|
|
232
250
|
return row.count;
|
|
233
251
|
};
|
|
234
252
|
|
|
@@ -311,9 +329,7 @@ const backupTargetBucketComposerData = async (
|
|
|
311
329
|
target: CursorWorkspaceBucket,
|
|
312
330
|
snapshot: BucketComposerDataSnapshot,
|
|
313
331
|
): Promise<string> => {
|
|
314
|
-
|
|
315
|
-
await Bun.write(backupPath, JSON.stringify(snapshot));
|
|
316
|
-
return backupPath;
|
|
332
|
+
return writeRetainedCursorBackup(target.dbPath, 'composerData', snapshot);
|
|
317
333
|
};
|
|
318
334
|
|
|
319
335
|
const buildTargetBucketComposerData = (existing: ComposerData, merged: ComposerEntry[]): ComposerData => {
|
|
@@ -394,7 +410,9 @@ const removeThreadFromBucket = (db: Database, composerIds: Set<string>): boolean
|
|
|
394
410
|
};
|
|
395
411
|
|
|
396
412
|
const pruneGlobalThread = (db: Database, composerId: string): { bubbles: number; composerData: number } => {
|
|
397
|
-
const bubbleResult = db.run(
|
|
413
|
+
const bubbleResult = db.run(`DELETE FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`, [
|
|
414
|
+
buildCursorBubbleKeyLikePattern(composerId),
|
|
415
|
+
]);
|
|
398
416
|
const headResult = db.run('DELETE FROM cursorDiskKV WHERE key = ?', [`composerData:${composerId}`]);
|
|
399
417
|
return { bubbles: bubbleResult.changes ?? 0, composerData: headResult.changes ?? 0 };
|
|
400
418
|
};
|
|
@@ -420,6 +438,28 @@ export const pruneCursorThreads = async (
|
|
|
420
438
|
apply: boolean,
|
|
421
439
|
userDir = resolveCursorUserDir(),
|
|
422
440
|
): Promise<CursorPruneResult> => {
|
|
441
|
+
const projectsDir = path.resolve(getCursorProjectsDir(userDir));
|
|
442
|
+
const canonicalProjectsDir = await realpath(projectsDir).catch(() => projectsDir);
|
|
443
|
+
for (const thread of threads) {
|
|
444
|
+
assertSafeCursorComposerId(thread.composerId);
|
|
445
|
+
for (const transcriptDir of thread.transcriptDirs) {
|
|
446
|
+
const resolvedDir = path.resolve(transcriptDir);
|
|
447
|
+
const resolvedProjectsDir = path.dirname(path.dirname(path.dirname(resolvedDir)));
|
|
448
|
+
const canonicalDir = await realpath(resolvedDir).catch(() => resolvedDir);
|
|
449
|
+
const canonicalRelativePath = path.relative(canonicalProjectsDir, canonicalDir);
|
|
450
|
+
if (
|
|
451
|
+
path.basename(resolvedDir) !== thread.composerId ||
|
|
452
|
+
path.basename(path.dirname(resolvedDir)) !== 'agent-transcripts' ||
|
|
453
|
+
resolvedProjectsDir !== projectsDir ||
|
|
454
|
+
!canonicalRelativePath ||
|
|
455
|
+
canonicalRelativePath.startsWith(`..${path.sep}`) ||
|
|
456
|
+
path.isAbsolute(canonicalRelativePath)
|
|
457
|
+
) {
|
|
458
|
+
throw new Error(`Unsafe Cursor transcript directory: ${transcriptDir}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
423
463
|
const composerIds = new Set(threads.map((thread) => thread.composerId));
|
|
424
464
|
const globalDbPath = getCursorGlobalDbPath(userDir);
|
|
425
465
|
const result: CursorPruneResult = {
|
|
@@ -444,8 +484,18 @@ export const pruneCursorThreads = async (
|
|
|
444
484
|
}
|
|
445
485
|
|
|
446
486
|
await backupPrunedThreads(globalDbPath, [...composerIds]);
|
|
447
|
-
await
|
|
448
|
-
|
|
487
|
+
const bucketMutation = await pruneWorkspaceBuckets(composerIds, userDir);
|
|
488
|
+
try {
|
|
489
|
+
await pruneGlobalThreads(globalDbPath, threads, composerIds, result);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
try {
|
|
492
|
+
await bucketMutation.rollback();
|
|
493
|
+
} catch (rollbackError) {
|
|
494
|
+
throw new AggregateError([error, rollbackError], 'Cursor deletion and bucket rollback both failed');
|
|
495
|
+
}
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
result.workspaceBucketsUpdated = bucketMutation.updatedCount;
|
|
449
499
|
await pruneTranscriptDirs(threads, result);
|
|
450
500
|
invalidateCursorDiscoveryCache();
|
|
451
501
|
|
|
@@ -459,25 +509,49 @@ const pruneGlobalThreads = async (
|
|
|
459
509
|
result: CursorPruneResult,
|
|
460
510
|
): Promise<void> => {
|
|
461
511
|
const db = new Database(globalDbPath);
|
|
512
|
+
let transactionStarted = false;
|
|
462
513
|
try {
|
|
514
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
515
|
+
db.exec('BEGIN IMMEDIATE');
|
|
516
|
+
transactionStarted = true;
|
|
517
|
+
let bubblesDeleted = 0;
|
|
518
|
+
let composerDataDeleted = 0;
|
|
463
519
|
for (const thread of threads) {
|
|
464
520
|
const deleted = pruneGlobalThread(db, thread.composerId);
|
|
465
|
-
|
|
466
|
-
|
|
521
|
+
bubblesDeleted += deleted.bubbles;
|
|
522
|
+
composerDataDeleted += deleted.composerData;
|
|
467
523
|
}
|
|
468
524
|
|
|
469
|
-
|
|
525
|
+
const headersRemoved = removeThreadHeaders(db, composerIds);
|
|
526
|
+
db.exec('COMMIT');
|
|
527
|
+
transactionStarted = false;
|
|
528
|
+
result.bubblesDeleted += bubblesDeleted;
|
|
529
|
+
result.composerDataDeleted += composerDataDeleted;
|
|
530
|
+
result.headersRemoved = headersRemoved;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (transactionStarted) {
|
|
533
|
+
db.exec('ROLLBACK');
|
|
534
|
+
}
|
|
535
|
+
throw error;
|
|
470
536
|
} finally {
|
|
471
537
|
db.close();
|
|
472
538
|
}
|
|
473
539
|
};
|
|
474
540
|
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
541
|
+
const restoreBucketComposerData = (dbPath: string, snapshot: BucketComposerDataSnapshot): void => {
|
|
542
|
+
const db = new Database(dbPath);
|
|
543
|
+
try {
|
|
544
|
+
if (snapshot.exists) {
|
|
545
|
+
writeJsonItem(db, COMPOSER_DATA_KEY, snapshot.data);
|
|
546
|
+
} else {
|
|
547
|
+
db.run('DELETE FROM ItemTable WHERE key = ?', [COMPOSER_DATA_KEY]);
|
|
548
|
+
}
|
|
549
|
+
} finally {
|
|
550
|
+
db.close();
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const pruneWorkspaceBuckets = async (composerIds: Set<string>, userDir: string) => {
|
|
481
555
|
// Scan every bucket: a thread can live in more than one bucket's composer.composerData (e.g. the
|
|
482
556
|
// current bucket plus the older bucket it was recovered from), so remove it from all of them.
|
|
483
557
|
const groups = await listCursorWorkspaceGroups(userDir);
|
|
@@ -488,25 +562,61 @@ const pruneWorkspaceBuckets = async (
|
|
|
488
562
|
}
|
|
489
563
|
}
|
|
490
564
|
|
|
565
|
+
const snapshots = new Map<string, BucketComposerDataSnapshot>();
|
|
491
566
|
for (const dbPath of dbPaths) {
|
|
492
|
-
const db =
|
|
567
|
+
const db = openCursorReadonlyDb(dbPath);
|
|
493
568
|
try {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
569
|
+
const data = readJsonItem<ComposerData>(db, COMPOSER_DATA_KEY);
|
|
570
|
+
snapshots.set(dbPath, { data: data ?? {}, exists: data !== null });
|
|
497
571
|
} finally {
|
|
498
572
|
db.close();
|
|
499
573
|
}
|
|
500
574
|
}
|
|
501
|
-
};
|
|
502
575
|
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
576
|
+
const updatedPaths: string[] = [];
|
|
577
|
+
const rollback = async () => {
|
|
578
|
+
const rollbackFailures: unknown[] = [];
|
|
579
|
+
for (const dbPath of [...updatedPaths].reverse()) {
|
|
580
|
+
try {
|
|
581
|
+
restoreBucketComposerData(dbPath, snapshots.get(dbPath)!);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
rollbackFailures.push(error);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (rollbackFailures.length > 0) {
|
|
587
|
+
throw new AggregateError(rollbackFailures, 'Failed to restore Cursor workspace buckets');
|
|
508
588
|
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
for (const dbPath of dbPaths) {
|
|
593
|
+
const db = new Database(dbPath);
|
|
594
|
+
try {
|
|
595
|
+
if (removeThreadFromBucket(db, composerIds)) {
|
|
596
|
+
updatedPaths.push(dbPath);
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
db.close();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
} catch (error) {
|
|
603
|
+
try {
|
|
604
|
+
await rollback();
|
|
605
|
+
} catch (rollbackError) {
|
|
606
|
+
throw new AggregateError([error, rollbackError], 'Cursor bucket deletion and rollback both failed');
|
|
607
|
+
}
|
|
608
|
+
throw error;
|
|
509
609
|
}
|
|
610
|
+
|
|
611
|
+
return { rollback, updatedCount: updatedPaths.length };
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const pruneTranscriptDirs = async (threads: CursorThreadSummary[], result: CursorPruneResult): Promise<void> => {
|
|
615
|
+
const transcriptDirs = threads.flatMap((thread) => thread.transcriptDirs);
|
|
616
|
+
await mapWithConcurrency(transcriptDirs, 4, async (dir) => {
|
|
617
|
+
await rm(dir, { force: true, recursive: true });
|
|
618
|
+
});
|
|
619
|
+
result.transcriptDirsRemoved = transcriptDirs.length;
|
|
510
620
|
};
|
|
511
621
|
|
|
512
622
|
// Builds the minimal thread records needed to fully delete the given composer ids (bubble counts for
|
|
@@ -521,6 +631,7 @@ export const collectCursorThreadsForDeletion = async (
|
|
|
521
631
|
|
|
522
632
|
try {
|
|
523
633
|
for (const composerId of composerIds) {
|
|
634
|
+
assertSafeCursorComposerId(composerId);
|
|
524
635
|
summaries.push({
|
|
525
636
|
bubbleBytes: 0,
|
|
526
637
|
bubbleCount: countBubbles(db, composerId),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CursorBubble } from './cursor-exporter-types';
|
|
2
|
+
|
|
3
|
+
export type CursorMessagePhase = 'commentary' | 'final_answer' | null;
|
|
4
|
+
|
|
5
|
+
export const getFinalCursorAssistantTextBubbleIds = (bubbles: CursorBubble[]): Set<string> => {
|
|
6
|
+
const finalBubbleIds = new Set<string>();
|
|
7
|
+
let latestAssistantTextBubbleId: string | null = null;
|
|
8
|
+
|
|
9
|
+
const flushAssistantRun = () => {
|
|
10
|
+
if (latestAssistantTextBubbleId) {
|
|
11
|
+
finalBubbleIds.add(latestAssistantTextBubbleId);
|
|
12
|
+
latestAssistantTextBubbleId = null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (const bubble of bubbles) {
|
|
17
|
+
if (bubble.kind !== 'assistant') {
|
|
18
|
+
flushAssistantRun();
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (bubble.text.trim()) {
|
|
23
|
+
latestAssistantTextBubbleId = bubble.bubbleId;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
flushAssistantRun();
|
|
28
|
+
return finalBubbleIds;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const getCursorTextBubblePhase = (
|
|
32
|
+
bubble: CursorBubble,
|
|
33
|
+
finalAssistantTextBubbleIds: Set<string>,
|
|
34
|
+
): CursorMessagePhase => {
|
|
35
|
+
if (bubble.kind !== 'assistant') {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return finalAssistantTextBubbleIds.has(bubble.bubbleId) ? 'final_answer' : 'commentary';
|
|
40
|
+
};
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
CursorThreadTranscript,
|
|
6
6
|
CursorToolCall,
|
|
7
7
|
} from './cursor-exporter-types';
|
|
8
|
+
import { getCursorTextBubblePhase, getFinalCursorAssistantTextBubbleIds } from './cursor-transcript-phase';
|
|
8
9
|
import {
|
|
9
10
|
cleanExtractedText,
|
|
10
11
|
cleanInlineTitle,
|
|
@@ -60,6 +61,9 @@ export const renderCursorToolCall = (toolCall: CursorToolCall, outputFormat: Exp
|
|
|
60
61
|
if (toolCall.status) {
|
|
61
62
|
lines.push(`Status: ${toolCall.status}`);
|
|
62
63
|
}
|
|
64
|
+
if (toolCall.callId) {
|
|
65
|
+
lines.push(`Call ID: ${toolCall.callId}`);
|
|
66
|
+
}
|
|
63
67
|
|
|
64
68
|
const args = prettyToolArguments(toolCall.argumentsText);
|
|
65
69
|
if (args) {
|
|
@@ -79,7 +83,11 @@ const renderUserBubble = (bubble: CursorBubble, outputFormat: ExportFormat): str
|
|
|
79
83
|
return text ? renderSection('User', text, outputFormat) : '';
|
|
80
84
|
};
|
|
81
85
|
|
|
82
|
-
const renderAssistantBubble = (
|
|
86
|
+
const renderAssistantBubble = (
|
|
87
|
+
bubble: CursorBubble,
|
|
88
|
+
options: CursorExportOptions,
|
|
89
|
+
finalAssistantTextBubbleIds: Set<string>,
|
|
90
|
+
): string[] => {
|
|
83
91
|
const blocks: string[] = [];
|
|
84
92
|
|
|
85
93
|
if (options.includeCommentary && bubble.thinking?.trim()) {
|
|
@@ -90,7 +98,10 @@ const renderAssistantBubble = (bubble: CursorBubble, options: CursorExportOption
|
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
const text = cleanExtractedText(bubble.text).trim();
|
|
93
|
-
if (
|
|
101
|
+
if (
|
|
102
|
+
text &&
|
|
103
|
+
(getCursorTextBubblePhase(bubble, finalAssistantTextBubbleIds) !== 'commentary' || options.includeCommentary)
|
|
104
|
+
) {
|
|
94
105
|
blocks.push(renderSection('Assistant', text, options.outputFormat));
|
|
95
106
|
}
|
|
96
107
|
|
|
@@ -101,14 +112,18 @@ const renderAssistantBubble = (bubble: CursorBubble, options: CursorExportOption
|
|
|
101
112
|
return blocks;
|
|
102
113
|
};
|
|
103
114
|
|
|
104
|
-
export const renderCursorBubble = (
|
|
115
|
+
export const renderCursorBubble = (
|
|
116
|
+
bubble: CursorBubble,
|
|
117
|
+
options: CursorExportOptions,
|
|
118
|
+
finalAssistantTextBubbleIds = getFinalCursorAssistantTextBubbleIds([bubble]),
|
|
119
|
+
): string[] => {
|
|
105
120
|
if (bubble.kind === 'user') {
|
|
106
121
|
const block = renderUserBubble(bubble, options.outputFormat);
|
|
107
122
|
return block ? [block] : [];
|
|
108
123
|
}
|
|
109
124
|
|
|
110
125
|
if (bubble.kind === 'assistant') {
|
|
111
|
-
return renderAssistantBubble(bubble, options);
|
|
126
|
+
return renderAssistantBubble(bubble, options, finalAssistantTextBubbleIds);
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
return [];
|
|
@@ -161,9 +176,10 @@ export const renderCursorTranscript = (
|
|
|
161
176
|
transcript: CursorThreadTranscript,
|
|
162
177
|
options: CursorExportOptions,
|
|
163
178
|
): string | null => {
|
|
179
|
+
const finalAssistantTextBubbleIds = getFinalCursorAssistantTextBubbleIds(transcript.bubbles);
|
|
164
180
|
const sections: string[] = [];
|
|
165
181
|
for (const bubble of transcript.bubbles) {
|
|
166
|
-
sections.push(...renderCursorBubble(bubble, options));
|
|
182
|
+
sections.push(...renderCursorBubble(bubble, options, finalAssistantTextBubbleIds));
|
|
167
183
|
}
|
|
168
184
|
|
|
169
185
|
if (sections.length === 0) {
|