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.
Files changed (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -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 { asNumber, asObject, asString, type JsonValue } from './shared';
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
- return new Database(getCursorReadonlyDbUri(dbPath), CURSOR_READONLY_DB_OPEN_FLAGS);
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
- return decodeURIComponent(uri.slice('file://'.length));
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('SELECT COUNT(*) AS count, COALESCE(SUM(length(value)), 0) AS bytes FROM cursorDiskKV WHERE key LIKE ?')
435
- .get(`bubbleId:${composerId}:%`) as { count: number; bytes: number };
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 transcriptDir = path.join(projectsDir, projectDir, 'agent-transcripts', composerId);
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('SELECT value FROM cursorDiskKV WHERE key LIKE ? LIMIT 80')
643
- .all(`bubbleId:${composerId}:%`) as Array<{ value: string }>;
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 Record<string, JsonValue>;
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 ? `file://${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.query('SELECT key FROM cursorDiskKV WHERE key LIKE ? ORDER BY key ASC').all(`${prefix}%`) as Array<{
1450
- key: string;
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 { rm } from 'node:fs/promises';
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
- const backupPath = `${globalDbPath}.composerHeaders.${backupStamp()}.json`;
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.query('SELECT key, value FROM cursorDiskKV WHERE key LIKE ?').all(`bubbleId:${composerId}:%`),
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
- const backupPath = `${globalDbPath}.prunedThreads.${backupStamp()}.json`;
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('SELECT COUNT(*) AS count FROM cursorDiskKV WHERE key LIKE ?')
231
- .get(`bubbleId:${composerId}:%`) as { count: number };
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
- const backupPath = `${target.dbPath}.composerData.${backupStamp()}.json`;
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('DELETE FROM cursorDiskKV WHERE key LIKE ?', [`bubbleId:${composerId}:%`]);
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 pruneGlobalThreads(globalDbPath, threads, composerIds, result);
448
- await pruneWorkspaceBuckets(threads, composerIds, result, userDir);
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
- result.bubblesDeleted += deleted.bubbles;
466
- result.composerDataDeleted += deleted.composerData;
521
+ bubblesDeleted += deleted.bubbles;
522
+ composerDataDeleted += deleted.composerData;
467
523
  }
468
524
 
469
- result.headersRemoved = removeThreadHeaders(db, composerIds);
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 pruneWorkspaceBuckets = async (
476
- _threads: CursorThreadSummary[],
477
- composerIds: Set<string>,
478
- result: CursorPruneResult,
479
- userDir: string,
480
- ): Promise<void> => {
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 = new Database(dbPath);
567
+ const db = openCursorReadonlyDb(dbPath);
493
568
  try {
494
- if (removeThreadFromBucket(db, composerIds)) {
495
- result.workspaceBucketsUpdated += 1;
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 pruneTranscriptDirs = async (threads: CursorThreadSummary[], result: CursorPruneResult): Promise<void> => {
504
- for (const thread of threads) {
505
- for (const dir of thread.transcriptDirs) {
506
- await rm(dir, { force: true, recursive: true });
507
- result.transcriptDirsRemoved += 1;
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 = (bubble: CursorBubble, options: CursorExportOptions): string[] => {
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 (text) {
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 = (bubble: CursorBubble, options: CursorExportOptions): string[] => {
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) {