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.
- package/AGENTS.md +27 -12
- package/README.md +37 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +17 -10
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
- package/apps/ui/src/components/app-shell.tsx +45 -3
- package/apps/ui/src/components/data-table.tsx +17 -1
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +77 -62
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -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 +4 -1
- package/apps/ui/src/lib/antigravity-server.ts +70 -17
- package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
- package/apps/ui/src/lib/claude-code-server.ts +14 -4
- package/apps/ui/src/lib/codex-queries.ts +3 -3
- package/apps/ui/src/lib/codex-server.ts +3 -3
- package/apps/ui/src/lib/cursor-server.ts +41 -69
- 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/grok-server.ts +2 -3
- package/apps/ui/src/lib/kiro-server.ts +14 -2
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +13 -2
- 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-transcript-events.ts +1 -1
- 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 +1 -1
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
- package/apps/ui/src/routeTree.gen.ts +282 -282
- 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 +73 -19
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
- 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 +29 -16
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
- package/apps/ui/src/routes/grok.index.tsx +2 -2
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
- package/apps/ui/src/routes/opencode.index.tsx +8 -17
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +26 -17
- package/src/client.ts +61 -16
- package/src/lib/antigravity-db.ts +236 -82
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +45 -19
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +2 -7
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +88 -33
- package/src/lib/codex-thread-parser.ts +78 -35
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-renderer.ts +49 -61
- package/src/lib/concurrency.ts +15 -3
- package/src/lib/conversation-api.ts +34 -31
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
- package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
- package/src/lib/conversation-data/codex-adapter.ts +91 -80
- package/src/lib/conversation-data/cursor-adapter.ts +48 -34
- package/src/lib/conversation-data/grok-adapter.ts +19 -6
- package/src/lib/conversation-data/index.ts +29 -10
- package/src/lib/conversation-data/kiro-adapter.ts +18 -5
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +43 -15
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +23 -18
- package/src/lib/conversation-zip-export.ts +20 -2
- package/src/lib/cursor-db.ts +34 -23
- 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 +76 -34
- package/src/lib/grok-transcript-phase.ts +11 -2
- package/src/lib/grok-transcript.ts +15 -2
- package/src/lib/kiro-db.ts +161 -68
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/opencode-db.ts +4 -8
- package/src/lib/opencode-think-tags.ts +17 -3
- 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 +51 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +8 -3
- package/src/lib/ui-cache.ts +152 -17
- package/src/lib/ui-export-archive.ts +14 -55
- 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
|
@@ -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) {
|
package/src/lib/grok-db.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
403
|
+
const archivedHistories = (
|
|
402
404
|
await Promise.all(
|
|
403
405
|
requestFiles.map(async (filePath) => {
|
|
404
406
|
const request = await readJsonObjectFile(filePath);
|
|
405
|
-
return
|
|
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) =>
|
|
415
|
+
.filter((history) => history.events.length > 0)
|
|
416
|
+
.sort((left, right) => left.createdAtMs - right.createdAtMs || left.filePath.localeCompare(right.filePath));
|
|
411
417
|
|
|
412
|
-
if (
|
|
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
|
|
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) =>
|
|
435
|
+
).sort((left, right) => left.createdAtMs - right.createdAtMs || left.filePath.localeCompare(right.filePath));
|
|
429
436
|
|
|
430
|
-
const
|
|
431
|
-
|
|
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
|
-
:
|
|
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
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
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
|
|
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
|
|
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' |
|
|
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
|
|
57
|
+
return null;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
return finalTextPartIds.has(part.partId) ? 'final_answer' : 'commentary';
|