spiracha 2.1.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 (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -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) {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { chmod, readdir, rename, rm, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
  import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
5
6
  import {
6
7
  type GrokSessionSummary,
@@ -12,15 +13,16 @@ import {
12
13
  resolveGrokHome,
13
14
  resolveGrokSessionsDir,
14
15
  } from './grok-exporter-types';
16
+ import { getPortablePathBasename } from './portable-path';
15
17
  import {
16
18
  asNumber,
17
19
  asObject,
18
20
  asString,
19
21
  cleanExtractedText,
20
22
  cleanInlineTitle,
21
- getPortablePathBasename,
22
23
  isWorkspacePathQuery,
23
24
  type JsonValue,
25
+ readDirectoryEntriesIfExists,
24
26
  readJsonlObjects,
25
27
  workspacePathMatchesQuery,
26
28
  } from './shared';
@@ -151,7 +153,7 @@ const readJsonObjectFile = async (filePath: string): Promise<Record<string, Json
151
153
  };
152
154
 
153
155
  const listJsonFiles = async (dirPath: string): Promise<string[]> => {
154
- const entries = await readdir(dirPath, { withFileTypes: true }).catch(() => []);
156
+ const entries = await readDirectoryEntriesIfExists(dirPath);
155
157
  return entries
156
158
  .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
157
159
  .map((entry) => path.join(dirPath, entry.name))
@@ -253,7 +255,7 @@ const parseAssistantParts = (
253
255
  includeRawPayloads: boolean,
254
256
  ): GrokTranscriptPart[] => {
255
257
  const parts: GrokTranscriptPart[] = [];
256
- const text = asString(raw.content ?? null) ?? '';
258
+ const text = textFromContentValue(raw.content);
257
259
  if (text.trim()) {
258
260
  parts.push({
259
261
  partId: `${entryId}:text`,
@@ -398,41 +400,73 @@ const readGrokChatHistory = async (
398
400
  }
399
401
 
400
402
  const requestFiles = await listJsonFiles(path.join(sessionDir, 'compaction_requests'));
401
- const archivedHistory = (
403
+ const archivedHistories = (
402
404
  await Promise.all(
403
405
  requestFiles.map(async (filePath) => {
404
406
  const request = await readJsonObjectFile(filePath);
405
- return getJsonObjectList(request?.chat_history);
407
+ return {
408
+ createdAtMs: parseTimestampMs(request?.created_at) ?? 0,
409
+ events: getJsonObjectList(request?.chat_history),
410
+ filePath,
411
+ };
406
412
  }),
407
413
  )
408
414
  )
409
- .filter((history) => history.length > 0)
410
- .sort((left, right) => right.length - left.length)[0];
415
+ .filter((history) => history.events.length > 0)
416
+ .sort((left, right) => left.createdAtMs - right.createdAtMs || left.filePath.localeCompare(right.filePath));
411
417
 
412
- if (!archivedHistory) {
418
+ if (archivedHistories.length === 0) {
413
419
  return liveEvents;
414
420
  }
415
421
 
416
422
  const checkpointFiles = await listJsonFiles(path.join(sessionDir, 'compaction_checkpoints'));
417
- const latestCheckpoint = (
423
+ const checkpoints = (
418
424
  await Promise.all(
419
425
  checkpointFiles.map(async (filePath) => {
420
426
  const checkpoint = await readJsonObjectFile(filePath);
421
427
  return {
422
428
  compactedHistory: getJsonObjectList(checkpoint?.compacted_history),
423
429
  createdAtMs: parseTimestampMs(checkpoint?.created_at) ?? 0,
430
+ filePath,
424
431
  promptIndex: asNumber(checkpoint?.prompt_index_at_compaction ?? null),
425
432
  };
426
433
  }),
427
434
  )
428
- ).sort((left, right) => right.createdAtMs - left.createdAtMs)[0];
435
+ ).sort((left, right) => left.createdAtMs - right.createdAtMs || left.filePath.localeCompare(right.filePath));
429
436
 
430
- const compactedHistoryIsLivePrefix = latestCheckpoint?.compactedHistory.every(
431
- (event, index) => JSON.stringify(event) === JSON.stringify(liveEvents[index]),
432
- );
437
+ const isHistoryPrefix = (prefix: Record<string, JsonValue>[], history: Record<string, JsonValue>[]): boolean => {
438
+ return (
439
+ prefix.length > 0 &&
440
+ prefix.length <= history.length &&
441
+ prefix.every((event, index) => isDeepStrictEqual(event, history[index]))
442
+ );
443
+ };
444
+
445
+ let archivedHistory = [...archivedHistories[0]!.events];
446
+ for (const history of archivedHistories.slice(1)) {
447
+ const precedingCheckpoint = checkpoints
448
+ .filter(
449
+ (checkpoint) =>
450
+ checkpoint.createdAtMs <= history.createdAtMs &&
451
+ isHistoryPrefix(checkpoint.compactedHistory, history.events),
452
+ )
453
+ .at(-1);
454
+ if (precedingCheckpoint) {
455
+ archivedHistory.push(...history.events.slice(precedingCheckpoint.compactedHistory.length));
456
+ } else if (isHistoryPrefix(archivedHistory, history.events)) {
457
+ archivedHistory = [...history.events];
458
+ }
459
+ }
460
+
461
+ const latestCheckpoint = checkpoints.at(-1);
462
+
463
+ const compactedHistoryIsLivePrefix = isHistoryPrefix(latestCheckpoint?.compactedHistory ?? [], liveEvents);
464
+ const archivedHistoryIsLivePrefix = isHistoryPrefix(archivedHistory, liveEvents);
433
465
  const liveTailStart = compactedHistoryIsLivePrefix
434
466
  ? latestCheckpoint?.compactedHistory.length
435
- : latestCheckpoint?.promptIndex;
467
+ : archivedHistoryIsLivePrefix
468
+ ? archivedHistory.length
469
+ : latestCheckpoint?.promptIndex;
436
470
  const liveTail = Number.isFinite(liveTailStart)
437
471
  ? liveEvents.slice(Math.max(0, Math.min(liveEvents.length, Math.floor(liveTailStart ?? 0))))
438
472
  : liveEvents;
@@ -662,23 +696,17 @@ const listSessionDirectoriesUnderWorkspace = async (
662
696
  root: string,
663
697
  directoryName: string,
664
698
  ): Promise<GrokSessionDirectory[]> => {
665
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
666
- const directories: GrokSessionDirectory[] = [];
667
-
668
- for (const entry of entries) {
669
- if (!entry.isDirectory()) {
670
- continue;
671
- }
672
-
673
- const entryPath = path.join(root, entry.name);
674
- if (
675
- (await pathExists(path.join(entryPath, 'summary.json'))) &&
676
- (await pathExists(path.join(entryPath, 'chat_history.jsonl')))
677
- ) {
678
- directories.push({ directoryName, sessionDir: entryPath });
679
- }
680
- directories.push(...(await listSessionDirectoriesUnderWorkspace(entryPath, directoryName)));
681
- }
699
+ const entries = await readDirectoryEntriesIfExists(root);
700
+ const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
701
+ const isSessionDirectory = fileNames.has('summary.json') && fileNames.has('chat_history.jsonl');
702
+ const childDirectories = entries.filter(
703
+ (entry) => entry.isDirectory() && (!isSessionDirectory || entry.name === 'subagents'),
704
+ );
705
+ const nested = await mapWithConcurrency(childDirectories, READ_CONCURRENCY, (entry) =>
706
+ listSessionDirectoriesUnderWorkspace(path.join(root, entry.name), directoryName),
707
+ );
708
+ const directories: GrokSessionDirectory[] = isSessionDirectory ? [{ directoryName, sessionDir: root }] : [];
709
+ directories.push(...nested.flat());
682
710
 
683
711
  return directories.sort((left, right) => left.sessionDir.localeCompare(right.sessionDir));
684
712
  };
@@ -818,6 +846,14 @@ export const listGrokSessionsForGroup = async (
818
846
  workspaceKey: string,
819
847
  sessionsDir = resolveGrokSessionsDir(),
820
848
  ): Promise<GrokSessionSummary[]> => {
849
+ const transcripts = await listGrokSessionTranscriptsForGroup(workspaceKey, sessionsDir);
850
+ return sortSessions(transcripts.map((transcript) => transcript.session));
851
+ };
852
+
853
+ export const listGrokSessionTranscriptsForGroup = async (
854
+ workspaceKey: string,
855
+ sessionsDir = resolveGrokSessionsDir(),
856
+ ): Promise<GrokSessionTranscript[]> => {
821
857
  const directoryName = getDirectoryNameFromWorkspaceKey(workspaceKey);
822
858
  if (!directoryName || !(await pathExists(sessionsDir))) {
823
859
  return [];
@@ -825,7 +861,13 @@ export const listGrokSessionsForGroup = async (
825
861
 
826
862
  const files = await listSessionDirectoriesForWorkspace(sessionsDir, directoryName);
827
863
  const transcripts = await readSessionDirectories(sessionsDir, files);
828
- return sortSessions(transcripts.filter(hasConversationMessages).map((transcript) => transcript.session));
864
+ return transcripts
865
+ .filter(hasConversationMessages)
866
+ .sort(
867
+ (left, right) =>
868
+ compareNullableMsDesc(left.session.lastActiveAtMs, right.session.lastActiveAtMs) ||
869
+ left.session.title.localeCompare(right.session.title),
870
+ );
829
871
  };
830
872
 
831
873
  const locateSessionDirectory = async (sessionsDir: string, sessionId: string): Promise<GrokSessionDirectory | null> => {
@@ -844,7 +886,7 @@ const locateSessionDirectory = async (sessionsDir: string, sessionId: string): P
844
886
  };
845
887
 
846
888
  const listFilesRecursively = async (root: string): Promise<string[]> => {
847
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
889
+ const entries = await readDirectoryEntriesIfExists(root);
848
890
  const files: string[] = [];
849
891
 
850
892
  for (const entry of entries) {
@@ -863,7 +905,7 @@ const listFilesRecursively = async (root: string): Promise<string[]> => {
863
905
  };
864
906
 
865
907
  const listDirectoriesRecursively = async (root: string): Promise<string[]> => {
866
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
908
+ const entries = await readDirectoryEntriesIfExists(root);
867
909
  const directories: string[] = [];
868
910
 
869
911
  for (const entry of entries) {
@@ -1,6 +1,11 @@
1
1
  import type { GrokTranscriptEntry, GrokTranscriptPart } from './grok-exporter-types';
2
2
 
3
- export type GrokAssistantMessagePhase = 'commentary' | 'final_answer' | 'unknown';
3
+ export type GrokAssistantMessagePhase = 'commentary' | 'final_answer' | null;
4
+
5
+ const isGrokCommentaryEnvelope = (entry: GrokTranscriptEntry, part: GrokTranscriptPart): boolean => {
6
+ const text = part.text?.trim() ?? '';
7
+ return entry.role === 'system' || text.startsWith('<user_info>') || text.startsWith('<system-reminder>');
8
+ };
4
9
 
5
10
  export const getFinalGrokAssistantTextPartIds = (entries: GrokTranscriptEntry[]): Set<string> => {
6
11
  const finalPartIds = new Set<string>();
@@ -44,8 +49,12 @@ export const getGrokTextPartPhase = (
44
49
  part: GrokTranscriptPart,
45
50
  finalTextPartIds: Set<string>,
46
51
  ): GrokAssistantMessagePhase => {
52
+ if (isGrokCommentaryEnvelope(entry, part)) {
53
+ return 'commentary';
54
+ }
55
+
47
56
  if (entry.role !== 'assistant') {
48
- return 'unknown';
57
+ return null;
49
58
  }
50
59
 
51
60
  return finalTextPartIds.has(part.partId) ? 'final_answer' : 'commentary';