cursor-history 0.15.0 → 0.16.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.
@@ -4,10 +4,9 @@
4
4
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
5
  import { readFile } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { homedir } from 'node:os';
8
7
  import JSZip from 'jszip';
9
8
  import { openDatabase as openDatabaseAsync, openDatabaseReadWrite as openDatabaseReadWriteAsync, ensureDriver, } from './database/index.js';
10
- import { getCursorDataPath, contractPath, normalizePath, pathsEqual } from '../lib/platform.js';
9
+ import { getCursorDataPath, getGlobalStoragePath, contractPath, normalizePath, pathsEqual, } from '../lib/platform.js';
11
10
  import { SessionNotFoundError } from '../lib/errors.js';
12
11
  import { parseChatData, getSearchSnippets } from './parser.js';
13
12
  import { openBackupDatabase, readBackupManifest } from './backup.js';
@@ -25,22 +24,6 @@ const CHAT_DATA_KEYS = [
25
24
  */
26
25
  const PROMPTS_KEY = 'aiService.prompts';
27
26
  const GENERATIONS_KEY = 'aiService.generations';
28
- /**
29
- * Get the global Cursor storage path
30
- */
31
- function getGlobalStoragePath() {
32
- const platform = process.platform;
33
- const home = homedir();
34
- if (platform === 'win32') {
35
- return join(process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming'), 'Cursor', 'User', 'globalStorage');
36
- }
37
- else if (platform === 'darwin') {
38
- return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage');
39
- }
40
- else {
41
- return join(home, '.config', 'Cursor', 'User', 'globalStorage');
42
- }
43
- }
44
27
  /**
45
28
  * Open a SQLite database file (read-only)
46
29
  * @deprecated Use openDatabaseAsync for new code
@@ -76,6 +59,162 @@ function closeDatabase(db) {
76
59
  function getBubbleRowId(rowKey) {
77
60
  return rowKey.split(':').pop() ?? null;
78
61
  }
62
+ function isRecord(value) {
63
+ return !!value && typeof value === 'object' && !Array.isArray(value);
64
+ }
65
+ /**
66
+ * A composer entry carries real metadata when it has any field beyond `composerId`.
67
+ * Synthetic `{ composerId }` stubs (fabricated from `selectedComposerIds` by
68
+ * getComposerData) must never be persisted back into `allComposers`, or they would
69
+ * parse to phantom sessions with a null title and a "now" timestamp.
70
+ */
71
+ function isHydratedComposer(composer) {
72
+ return Object.keys(composer).some((key) => key !== 'composerId');
73
+ }
74
+ function parseDateValue(value) {
75
+ if (typeof value !== 'string' && typeof value !== 'number') {
76
+ return null;
77
+ }
78
+ // Epoch-ms timestamps can arrive as numeric strings ("1778672423842"); new Date()
79
+ // treats those as invalid, so coerce all-digit strings to a number first.
80
+ const normalized = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value;
81
+ const date = new Date(normalized);
82
+ return Number.isNaN(date.getTime()) ? null : date;
83
+ }
84
+ function uriToPath(uri) {
85
+ try {
86
+ return decodeURIComponent(uri.replace(/^file:\/\//, ''));
87
+ }
88
+ catch {
89
+ return uri.replace(/^file:\/\//, '');
90
+ }
91
+ }
92
+ function workspacePathMatches(candidatePath, workspacePath) {
93
+ return typeof candidatePath === 'string' && pathsEqual(uriToPath(candidatePath), workspacePath);
94
+ }
95
+ function composerBelongsToWorkspace(composerData, workspace) {
96
+ const workspaceIdentifier = composerData['workspaceIdentifier'];
97
+ if (isRecord(workspaceIdentifier)) {
98
+ if (workspaceIdentifier['id'] === workspace.id) {
99
+ return true;
100
+ }
101
+ const uri = workspaceIdentifier['uri'];
102
+ if (isRecord(uri)) {
103
+ if (workspacePathMatches(uri['fsPath'], workspace.path) ||
104
+ workspacePathMatches(uri['path'], workspace.path) ||
105
+ workspacePathMatches(uri['external'], workspace.path)) {
106
+ return true;
107
+ }
108
+ }
109
+ }
110
+ return workspacePathMatches(composerData['workspaceUri'], workspace.path);
111
+ }
112
+ /** Whether a global composer record carries any explicit workspace attribution. */
113
+ function composerHasWorkspaceStamp(composerData) {
114
+ if (isRecord(composerData['workspaceIdentifier'])) {
115
+ return true;
116
+ }
117
+ const workspaceUri = composerData['workspaceUri'];
118
+ return typeof workspaceUri === 'string' && workspaceUri.length > 0;
119
+ }
120
+ /**
121
+ * Best-effort workspace path for a global composer that is not attributed to any
122
+ * discovered workspace, derived from whatever workspace metadata the record does
123
+ * carry. Returns null when the record has no usable workspace hint.
124
+ */
125
+ function workspacePathFromComposer(composerData) {
126
+ const workspaceIdentifier = composerData['workspaceIdentifier'];
127
+ if (isRecord(workspaceIdentifier) && isRecord(workspaceIdentifier['uri'])) {
128
+ const uri = workspaceIdentifier['uri'];
129
+ for (const field of ['fsPath', 'path', 'external']) {
130
+ const value = uri[field];
131
+ if (typeof value === 'string' && value.length > 0) {
132
+ return uriToPath(value);
133
+ }
134
+ }
135
+ }
136
+ const workspaceUri = composerData['workspaceUri'];
137
+ if (typeof workspaceUri === 'string' && workspaceUri.length > 0) {
138
+ return uriToPath(workspaceUri);
139
+ }
140
+ return null;
141
+ }
142
+ function extractComposerIdsFromData(dataText) {
143
+ if (!dataText) {
144
+ return [];
145
+ }
146
+ try {
147
+ const parsed = JSON.parse(dataText);
148
+ const ids = new Map();
149
+ if (Array.isArray(parsed)) {
150
+ for (const entry of parsed) {
151
+ if (!entry || typeof entry !== 'object')
152
+ continue;
153
+ const composerId = entry.composerId;
154
+ if (typeof composerId === 'string' && composerId.trim().length > 0) {
155
+ ids.set(composerId, 'allComposers');
156
+ }
157
+ }
158
+ }
159
+ else if (parsed && typeof parsed === 'object') {
160
+ const allComposers = parsed.allComposers;
161
+ if (Array.isArray(allComposers)) {
162
+ for (const entry of allComposers) {
163
+ if (!entry || typeof entry !== 'object')
164
+ continue;
165
+ const composerId = entry.composerId;
166
+ if (typeof composerId === 'string' && composerId.trim().length > 0) {
167
+ ids.set(composerId, 'allComposers');
168
+ }
169
+ }
170
+ }
171
+ const selected = parsed.selectedComposerIds;
172
+ if (Array.isArray(selected)) {
173
+ for (const composerId of selected) {
174
+ if (typeof composerId === 'string' && composerId.trim().length > 0 && !ids.has(composerId)) {
175
+ ids.set(composerId, 'selectedComposerIds');
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return [...ids.entries()].map(([composerId, source]) => ({ composerId, source }));
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ }
186
+ const COMPOSER_GUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
187
+ /**
188
+ * Modern Cursor links a workspace to its agent/composer sessions through
189
+ * per-workspace UI-state pointer keys (e.g. `workbench.panel.composerChatViewPane.<guid>`)
190
+ * rather than stamping the workspace into the global composer record. Extract the
191
+ * composer GUIDs referenced by those pointers so they can be resolved from global
192
+ * storage. GUIDs that are not real composers simply resolve to nothing downstream.
193
+ */
194
+ function getWorkspaceComposerPointerIds(db) {
195
+ try {
196
+ const rows = db
197
+ .prepare("SELECT key, value FROM ItemTable WHERE key LIKE '%composerChatViewPane%'")
198
+ .all();
199
+ const ids = new Set();
200
+ for (const row of rows) {
201
+ for (const source of [row.key, row.value]) {
202
+ if (typeof source !== 'string')
203
+ continue;
204
+ const matches = source.match(COMPOSER_GUID_RE);
205
+ if (matches) {
206
+ for (const match of matches) {
207
+ ids.add(match.toLowerCase());
208
+ }
209
+ }
210
+ }
211
+ }
212
+ return [...ids];
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ }
79
218
  function parseToolParams(paramsText, rawArgsText) {
80
219
  const rawText = paramsText ?? rawArgsText;
81
220
  if (typeof rawText !== 'string' || rawText.trim().length === 0) {
@@ -386,6 +525,14 @@ export async function findWorkspaces(customDataPath, backupPath) {
386
525
  return [];
387
526
  }
388
527
  const workspaces = [];
528
+ let globalDb = null;
529
+ let globalDbChecked = false;
530
+ let globalDbAvailable = false;
531
+ let globalComposerRecords = [];
532
+ let globalBubbleCounts = new Map();
533
+ // Run-level set so a global composer is counted for at most one workspace,
534
+ // keeping `list --workspaces` counts consistent with the deduped `list` output.
535
+ const attributedComposerIds = new Set();
389
536
  try {
390
537
  const entries = readdirSync(basePath, { withFileTypes: true });
391
538
  for (const entry of entries) {
@@ -395,24 +542,102 @@ export async function findWorkspaces(customDataPath, backupPath) {
395
542
  const dbPath = join(workspaceDir, 'state.vscdb');
396
543
  if (!existsSync(dbPath))
397
544
  continue;
398
- const workspacePath = readWorkspaceJson(workspaceDir);
399
- if (!workspacePath)
400
- continue;
545
+ const workspacePath = readWorkspaceJson(workspaceDir) ?? `(workspace: ${entry.name})`;
546
+ if (workspacePath.startsWith('(workspace:')) {
547
+ debugLogStorage(`Using workspace ID fallback path for ${entry.name} (workspace.json missing/unknown)`);
548
+ }
401
549
  // Count sessions in this workspace
402
550
  let sessionCount = 0;
551
+ const seenComposerIds = new Set();
552
+ const selectedIds = [];
553
+ const pointerIds = [];
403
554
  try {
404
555
  const db = await openDatabase(dbPath);
405
556
  const result = getChatDataFromDb(db);
406
557
  if (result) {
407
558
  const parsed = parseChatData(result.data, result.bundle);
408
559
  sessionCount = parsed.length;
560
+ for (const session of parsed) {
561
+ seenComposerIds.add(session.id);
562
+ attributedComposerIds.add(session.id);
563
+ }
564
+ const rawComposerData = result.bundle.composerData;
565
+ const composerRefs = extractComposerIdsFromData(rawComposerData);
566
+ selectedIds.push(...composerRefs
567
+ .filter((ref) => ref.source === 'selectedComposerIds')
568
+ .map((ref) => ref.composerId));
569
+ }
570
+ else {
571
+ debugLogStorage(`No chat data keys found in workspace DB ${dbPath}`);
409
572
  }
573
+ // Pointer keys live in ItemTable independently of composer.composerData.
574
+ pointerIds.push(...getWorkspaceComposerPointerIds(db));
410
575
  db.close();
411
576
  }
412
- catch {
577
+ catch (error) {
578
+ debugLogStorage(`Skipping unreadable workspace DB ${dbPath}: ${getErrorMessage(error)}`);
413
579
  // Skip workspaces with unreadable databases
414
580
  continue;
415
581
  }
582
+ if (!globalDbChecked) {
583
+ globalDbChecked = true;
584
+ const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
585
+ if (existsSync(globalDbPath)) {
586
+ try {
587
+ globalDb = await openDatabase(globalDbPath);
588
+ const tableCheck = globalDb
589
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
590
+ .get();
591
+ globalDbAvailable = Boolean(tableCheck);
592
+ if (globalDbAvailable) {
593
+ globalComposerRecords = loadGlobalComposerRecords(globalDb);
594
+ globalBubbleCounts = loadGlobalBubbleCounts(globalDb);
595
+ }
596
+ }
597
+ catch {
598
+ closeDatabase(globalDb);
599
+ globalDb = null;
600
+ globalDbAvailable = false;
601
+ }
602
+ }
603
+ }
604
+ if (globalDb && globalDbAvailable) {
605
+ // Selected + pointer IDs are only counted when they resolve to a real
606
+ // global composer with bubbles, so non-composer GUIDs never inflate counts.
607
+ for (const composerId of [...selectedIds, ...pointerIds]) {
608
+ if (seenComposerIds.has(composerId) || attributedComposerIds.has(composerId))
609
+ continue;
610
+ if ((globalBubbleCounts.get(composerId) ?? 0) > 0) {
611
+ seenComposerIds.add(composerId);
612
+ attributedComposerIds.add(composerId);
613
+ sessionCount++;
614
+ }
615
+ }
616
+ const workspaceForGlobalMatch = {
617
+ id: entry.name,
618
+ path: workspacePath,
619
+ dbPath,
620
+ sessionCount,
621
+ };
622
+ for (const summary of getGlobalComposerSummariesForWorkspace(globalDb, workspaceForGlobalMatch, globalComposerRecords, globalBubbleCounts)) {
623
+ if (seenComposerIds.has(summary.id) || attributedComposerIds.has(summary.id))
624
+ continue;
625
+ seenComposerIds.add(summary.id);
626
+ attributedComposerIds.add(summary.id);
627
+ sessionCount++;
628
+ }
629
+ }
630
+ else {
631
+ // No global storage: pointer GUIDs cannot be confirmed as composers, so
632
+ // only count real selectedComposerIds (avoid phantom session counts).
633
+ for (const composerId of selectedIds) {
634
+ if (seenComposerIds.has(composerId) || attributedComposerIds.has(composerId))
635
+ continue;
636
+ seenComposerIds.add(composerId);
637
+ attributedComposerIds.add(composerId);
638
+ sessionCount++;
639
+ }
640
+ }
416
641
  if (sessionCount > 0) {
417
642
  workspaces.push({
418
643
  id: entry.name,
@@ -426,6 +651,9 @@ export async function findWorkspaces(customDataPath, backupPath) {
426
651
  catch {
427
652
  return [];
428
653
  }
654
+ finally {
655
+ closeDatabase(globalDb);
656
+ }
429
657
  return workspaces;
430
658
  }
431
659
  /**
@@ -433,27 +661,40 @@ export async function findWorkspaces(customDataPath, backupPath) {
433
661
  * Returns both the main chat data and the bundle for new format
434
662
  */
435
663
  function getChatDataFromDb(db) {
436
- let mainData = null;
437
- const bundle = {};
438
- // Try to get the main chat data
664
+ const candidates = [];
439
665
  for (const key of CHAT_DATA_KEYS) {
440
666
  try {
441
667
  const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key);
442
668
  if (row?.value) {
443
- mainData = row.value;
444
- if (key === 'composer.composerData') {
445
- bundle.composerData = row.value;
446
- }
447
- break;
669
+ candidates.push({ key, value: row.value });
448
670
  }
449
671
  }
450
672
  catch {
451
673
  continue;
452
674
  }
453
675
  }
454
- if (!mainData) {
676
+ if (candidates.length === 0) {
455
677
  return null;
456
678
  }
679
+ let selected = candidates[0];
680
+ let bestSessionCount = -1;
681
+ for (const candidate of candidates) {
682
+ const candidateBundle = {};
683
+ if (candidate.key === 'composer.composerData') {
684
+ candidateBundle.composerData = candidate.value;
685
+ }
686
+ const parsedCount = parseChatData(candidate.value, candidateBundle).length;
687
+ if (parsedCount > bestSessionCount) {
688
+ selected = candidate;
689
+ bestSessionCount = parsedCount;
690
+ }
691
+ }
692
+ const mainData = selected.value;
693
+ const bundle = {};
694
+ const composerCandidate = candidates.find((candidate) => candidate.key === 'composer.composerData');
695
+ if (composerCandidate) {
696
+ bundle.composerData = composerCandidate.value;
697
+ }
457
698
  // For new format, also get prompts and generations
458
699
  try {
459
700
  const promptsRow = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(PROMPTS_KEY);
@@ -475,6 +716,193 @@ function getChatDataFromDb(db) {
475
716
  }
476
717
  return { data: mainData, bundle };
477
718
  }
719
+ /**
720
+ * Count bubbles per composer in a single pass over global storage, instead of one
721
+ * `COUNT(*) ... LIKE 'bubbleId:<id>:%'` full scan per composer. Reused across the
722
+ * recovery passes so listing stays roughly one bubble-table scan rather than O(C).
723
+ */
724
+ function loadGlobalBubbleCounts(db) {
725
+ const counts = new Map();
726
+ try {
727
+ const rows = db
728
+ .prepare("SELECT key FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'")
729
+ .all();
730
+ for (const row of rows) {
731
+ // key form: bubbleId:<composerId>:<bubbleId>
732
+ const composerId = row.key.split(':')[1];
733
+ if (composerId) {
734
+ counts.set(composerId, (counts.get(composerId) ?? 0) + 1);
735
+ }
736
+ }
737
+ }
738
+ catch {
739
+ // Fall back to per-composer counting when the scan fails.
740
+ }
741
+ return counts;
742
+ }
743
+ function buildGlobalComposerSummary(db, composerId, composerData, options) {
744
+ const messageCount = options?.bubbleCount ??
745
+ (db
746
+ .prepare('SELECT COUNT(*) as count FROM cursorDiskKV WHERE key LIKE ?')
747
+ .get(`bubbleId:${composerId}:%`).count);
748
+ if (messageCount <= 0) {
749
+ return null;
750
+ }
751
+ let preview = '';
752
+ if (options?.includePreview !== false) {
753
+ const firstBubble = db
754
+ .prepare('SELECT value FROM cursorDiskKV WHERE key LIKE ? ORDER BY rowid ASC LIMIT 1')
755
+ .get(`bubbleId:${composerId}:%`);
756
+ if (firstBubble?.value) {
757
+ try {
758
+ const bubbleData = JSON.parse(firstBubble.value);
759
+ preview = extractBubbleText(bubbleData).slice(0, 100);
760
+ }
761
+ catch {
762
+ preview = '';
763
+ }
764
+ }
765
+ }
766
+ const createdAt = parseDateValue(composerData['createdAt']) ?? new Date();
767
+ const lastUpdatedAt = parseDateValue(composerData['lastUpdatedAt']) ??
768
+ parseDateValue(composerData['updatedAt']) ??
769
+ createdAt;
770
+ return {
771
+ id: composerId,
772
+ title: typeof composerData['name'] === 'string'
773
+ ? composerData['name']
774
+ : typeof composerData['title'] === 'string'
775
+ ? composerData['title']
776
+ : null,
777
+ createdAt,
778
+ lastUpdatedAt,
779
+ messageCount,
780
+ preview,
781
+ };
782
+ }
783
+ function getGlobalComposerSummary(db, composerId, bubbleCounts) {
784
+ try {
785
+ const composerRow = db.prepare('SELECT value FROM cursorDiskKV WHERE key = ?').get(`composerData:${composerId}`);
786
+ if (!composerRow?.value) {
787
+ return null;
788
+ }
789
+ const composerData = JSON.parse(composerRow.value);
790
+ if (!isRecord(composerData)) {
791
+ return null;
792
+ }
793
+ return buildGlobalComposerSummary(db, composerId, composerData, {
794
+ bubbleCount: bubbleCounts?.get(composerId),
795
+ });
796
+ }
797
+ catch {
798
+ return null;
799
+ }
800
+ }
801
+ /**
802
+ * Load and parse every `composerData:%` row from global storage exactly once.
803
+ * Callers reuse the result across all workspaces instead of re-scanning and
804
+ * re-parsing the full composer table per workspace (which made `list` O(W×C)).
805
+ */
806
+ function loadGlobalComposerRecords(db) {
807
+ try {
808
+ const rows = db
809
+ .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")
810
+ .all();
811
+ const records = [];
812
+ for (const row of rows) {
813
+ try {
814
+ const data = JSON.parse(row.value);
815
+ if (isRecord(data)) {
816
+ records.push({ id: row.key.replace('composerData:', ''), data });
817
+ }
818
+ }
819
+ catch {
820
+ continue;
821
+ }
822
+ }
823
+ return records;
824
+ }
825
+ catch {
826
+ return [];
827
+ }
828
+ }
829
+ function getGlobalComposerSummariesForWorkspace(db, workspace, records, bubbleCounts) {
830
+ const summaries = [];
831
+ for (const record of records) {
832
+ if (!composerBelongsToWorkspace(record.data, workspace)) {
833
+ continue;
834
+ }
835
+ const summary = buildGlobalComposerSummary(db, record.id, record.data, {
836
+ bubbleCount: bubbleCounts?.get(record.id),
837
+ });
838
+ if (summary) {
839
+ summaries.push(summary);
840
+ }
841
+ }
842
+ return summaries;
843
+ }
844
+ /**
845
+ * Return the composer IDs whose global-storage record is linked to `workspace`
846
+ * (via workspaceIdentifier or workspaceUri). Used by workspace migration so that
847
+ * sessions discoverable only through global storage are migrated too, matching
848
+ * what `list` surfaces for the workspace.
849
+ */
850
+ export async function getWorkspaceLinkedComposerIds(workspace, customDataPath) {
851
+ const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
852
+ if (!existsSync(globalDbPath)) {
853
+ return [];
854
+ }
855
+ let db = null;
856
+ try {
857
+ db = await openDatabase(globalDbPath);
858
+ const tableCheck = db
859
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
860
+ .get();
861
+ if (!tableCheck) {
862
+ return [];
863
+ }
864
+ const ids = new Set();
865
+ const records = loadGlobalComposerRecords(db);
866
+ const recordById = new Map(records.map((record) => [record.id, record.data]));
867
+ for (const record of records) {
868
+ if (composerBelongsToWorkspace(record.data, workspace)) {
869
+ ids.add(record.id);
870
+ }
871
+ }
872
+ // Also include composers referenced by this workspace's pointer keys (the
873
+ // modern linkage). Because migration is destructive, only include a pointer ID
874
+ // when its record is unstamped or already belongs to this workspace — never one
875
+ // explicitly stamped for a different workspace (which the user merely viewed).
876
+ if (existsSync(workspace.dbPath)) {
877
+ let wsDb = null;
878
+ try {
879
+ wsDb = await openDatabase(workspace.dbPath);
880
+ for (const pointerId of getWorkspaceComposerPointerIds(wsDb)) {
881
+ const data = recordById.get(pointerId);
882
+ if (!data)
883
+ continue;
884
+ if (!composerHasWorkspaceStamp(data) || composerBelongsToWorkspace(data, workspace)) {
885
+ ids.add(pointerId);
886
+ }
887
+ }
888
+ }
889
+ catch {
890
+ // Ignore unreadable workspace DB; global-linked IDs are still returned.
891
+ }
892
+ finally {
893
+ closeDatabase(wsDb);
894
+ }
895
+ }
896
+ return [...ids];
897
+ }
898
+ catch (error) {
899
+ debugLogStorage(`Failed to load workspace-linked composer IDs: ${getErrorMessage(error)}`);
900
+ return [];
901
+ }
902
+ finally {
903
+ closeDatabase(db);
904
+ }
905
+ }
478
906
  /**
479
907
  * List chat sessions with optional filtering
480
908
  * Uses workspace storage for listing (has correct paths and complete list)
@@ -503,6 +931,7 @@ export async function listSessions(options, customDataPath, backupPath) {
503
931
  const allSessions = [];
504
932
  // When listing all workspaces (no filter), dedupe by session id; keep first occurrence (workspace order is already deterministic)
505
933
  const seenIds = options.workspacePath ? null : new Set();
934
+ const globalFallbackCandidates = [];
506
935
  for (const workspace of filteredWorkspaces) {
507
936
  try {
508
937
  // Open database from live or backup source
@@ -510,11 +939,26 @@ export async function listSessions(options, customDataPath, backupPath) {
510
939
  ? await openBackupDatabase(backupPath, workspace.dbPath)
511
940
  : await openDatabase(workspace.dbPath);
512
941
  const result = getChatDataFromDb(db);
942
+ // Pointer keys (e.g. composerChatViewPane.<guid>) live in ItemTable and link
943
+ // this workspace to its global composers even when no workspace stamp exists.
944
+ const pointerIds = backupPath ? [] : getWorkspaceComposerPointerIds(db);
513
945
  db.close();
514
- if (!result)
515
- continue;
516
- const sessions = parseChatData(result.data, result.bundle);
946
+ const sessions = result ? parseChatData(result.data, result.bundle) : [];
947
+ const workspaceSeenIds = new Set();
948
+ const selectedIds = [];
949
+ if (result) {
950
+ const rawComposerData = result.bundle.composerData;
951
+ const composerRefs = extractComposerIdsFromData(rawComposerData);
952
+ selectedIds.push(...composerRefs
953
+ .filter((ref) => ref.source === 'selectedComposerIds')
954
+ .map((ref) => ref.composerId));
955
+ }
956
+ selectedIds.push(...pointerIds);
957
+ if (selectedIds.length > 0) {
958
+ debugLogStorage(`Workspace ${workspace.id} has ${selectedIds.length} global recovery candidate(s)`);
959
+ }
517
960
  for (const session of sessions) {
961
+ workspaceSeenIds.add(session.id);
518
962
  if (seenIds?.has(session.id))
519
963
  continue;
520
964
  seenIds?.add(session.id);
@@ -530,11 +974,136 @@ export async function listSessions(options, customDataPath, backupPath) {
530
974
  preview: session.messages[0]?.content.slice(0, 100) ?? '(Empty session)',
531
975
  });
532
976
  }
977
+ if (!backupPath) {
978
+ globalFallbackCandidates.push({
979
+ workspace,
980
+ composerIds: selectedIds,
981
+ existingIds: workspaceSeenIds,
982
+ includeWorkspaceLinked: true,
983
+ });
984
+ }
533
985
  }
534
- catch {
986
+ catch (error) {
987
+ debugLogStorage(`Skipping workspace ${workspace.id} while listing sessions: ${getErrorMessage(error)}`);
535
988
  continue;
536
989
  }
537
990
  }
991
+ if (!backupPath && (globalFallbackCandidates.length > 0 || !options.workspacePath)) {
992
+ const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
993
+ if (existsSync(globalDbPath)) {
994
+ let globalDb = null;
995
+ try {
996
+ globalDb = await openDatabase(globalDbPath);
997
+ const tableCheck = globalDb
998
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
999
+ .get();
1000
+ if (tableCheck) {
1001
+ const globalComposerRecords = loadGlobalComposerRecords(globalDb);
1002
+ // One pass over the bubble table instead of two LIKE scans per composer.
1003
+ const globalBubbleCounts = loadGlobalBubbleCounts(globalDb);
1004
+ const summaryCache = new Map();
1005
+ // Dedup recovered sessions across candidates even under `--workspace`
1006
+ // (where the shared `seenIds` is intentionally null), so a single global
1007
+ // composer matching multiple workspace dirs is listed at most once.
1008
+ const recoveredIds = new Set();
1009
+ for (const candidate of globalFallbackCandidates) {
1010
+ for (const composerId of candidate.composerIds) {
1011
+ if (candidate.existingIds.has(composerId))
1012
+ continue;
1013
+ if (seenIds?.has(composerId))
1014
+ continue;
1015
+ if (recoveredIds.has(composerId))
1016
+ continue;
1017
+ if (!summaryCache.has(composerId)) {
1018
+ summaryCache.set(composerId, getGlobalComposerSummary(globalDb, composerId, globalBubbleCounts));
1019
+ }
1020
+ const summary = summaryCache.get(composerId);
1021
+ if (!summary) {
1022
+ continue;
1023
+ }
1024
+ candidate.existingIds.add(summary.id);
1025
+ seenIds?.add(summary.id);
1026
+ recoveredIds.add(summary.id);
1027
+ allSessions.push({
1028
+ id: summary.id,
1029
+ index: 0,
1030
+ title: summary.title,
1031
+ createdAt: summary.createdAt,
1032
+ lastUpdatedAt: summary.lastUpdatedAt,
1033
+ messageCount: summary.messageCount,
1034
+ workspaceId: candidate.workspace.id,
1035
+ workspacePath: contractPath(candidate.workspace.path),
1036
+ preview: summary.preview || '(Empty session)',
1037
+ });
1038
+ }
1039
+ if (candidate.includeWorkspaceLinked) {
1040
+ for (const summary of getGlobalComposerSummariesForWorkspace(globalDb, candidate.workspace, globalComposerRecords, globalBubbleCounts)) {
1041
+ if (candidate.existingIds.has(summary.id))
1042
+ continue;
1043
+ if (seenIds?.has(summary.id))
1044
+ continue;
1045
+ if (recoveredIds.has(summary.id))
1046
+ continue;
1047
+ candidate.existingIds.add(summary.id);
1048
+ seenIds?.add(summary.id);
1049
+ recoveredIds.add(summary.id);
1050
+ allSessions.push({
1051
+ id: summary.id,
1052
+ index: 0,
1053
+ title: summary.title,
1054
+ createdAt: summary.createdAt,
1055
+ lastUpdatedAt: summary.lastUpdatedAt,
1056
+ messageCount: summary.messageCount,
1057
+ workspaceId: candidate.workspace.id,
1058
+ workspacePath: contractPath(candidate.workspace.path),
1059
+ preview: summary.preview || '(Empty session)',
1060
+ });
1061
+ }
1062
+ }
1063
+ }
1064
+ // Catch-all: surface global composers that could not be attributed to any
1065
+ // workspace (modern Cursor frequently stores no workspace stamp on the
1066
+ // global record). Only on the unfiltered listing, where `seenIds` tracks
1067
+ // everything already shown.
1068
+ if (seenIds && !options.workspacePath) {
1069
+ for (const record of globalComposerRecords) {
1070
+ if (seenIds.has(record.id) || recoveredIds.has(record.id))
1071
+ continue;
1072
+ // Use the precomputed bubble counts and skip the per-composer
1073
+ // first-bubble preview query so the catch-all stays ~one scan total
1074
+ // even when hundreds of composers are unattributed.
1075
+ const summary = buildGlobalComposerSummary(globalDb, record.id, record.data, {
1076
+ bubbleCount: globalBubbleCounts.get(record.id) ?? 0,
1077
+ includePreview: false,
1078
+ });
1079
+ if (!summary)
1080
+ continue;
1081
+ seenIds.add(summary.id);
1082
+ recoveredIds.add(summary.id);
1083
+ const derivedPath = workspacePathFromComposer(record.data);
1084
+ allSessions.push({
1085
+ id: summary.id,
1086
+ index: 0,
1087
+ title: summary.title,
1088
+ createdAt: summary.createdAt,
1089
+ lastUpdatedAt: summary.lastUpdatedAt,
1090
+ messageCount: summary.messageCount,
1091
+ workspaceId: 'global',
1092
+ workspacePath: derivedPath ? contractPath(derivedPath) : '(global)',
1093
+ preview: summary.preview || '(Empty session)',
1094
+ });
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+ catch (error) {
1100
+ debugLogStorage(`Failed to load global fallback sessions: ${getErrorMessage(error)}`);
1101
+ }
1102
+ finally {
1103
+ closeDatabase(globalDb);
1104
+ }
1105
+ }
1106
+ }
538
1107
  // Sort by most recent first
539
1108
  allSessions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
540
1109
  // Assign indexes
@@ -583,7 +1152,7 @@ export async function getSession(identifier, customDataPath, backupPath) {
583
1152
  // This works for both live data and backup (if backup includes globalStorage)
584
1153
  let globalDb = null;
585
1154
  let globalLoadFailed = false;
586
- const globalDbPath = join(getGlobalStoragePath(), 'state.vscdb');
1155
+ const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
587
1156
  try {
588
1157
  if (backupPath) {
589
1158
  try {
@@ -741,8 +1310,8 @@ export async function searchSessions(query, options, customDataPath, backupPath)
741
1310
  * List sessions from global Cursor storage (cursorDiskKV table)
742
1311
  * This is where Cursor stores full conversation data including AI responses
743
1312
  */
744
- export async function listGlobalSessions() {
745
- const globalPath = getGlobalStoragePath();
1313
+ export async function listGlobalSessions(customDataPath) {
1314
+ const globalPath = getGlobalStoragePath(customDataPath);
746
1315
  const dbPath = join(globalPath, 'state.vscdb');
747
1316
  if (!existsSync(dbPath)) {
748
1317
  return [];
@@ -786,16 +1355,20 @@ export async function listGlobalSessions() {
786
1355
  // Ignore
787
1356
  }
788
1357
  }
789
- const createdAt = data.createdAt ? new Date(data.createdAt) : new Date();
790
- const workspacePath = data.workspaceUri
791
- ? data.workspaceUri.replace(/^file:\/\//, '').replace(/%20/g, ' ')
792
- : 'Global';
1358
+ const createdAt = parseDateValue(data.createdAt) ?? new Date();
1359
+ const workspacePath = data.workspaceIdentifier?.uri?.fsPath ??
1360
+ data.workspaceIdentifier?.uri?.path ??
1361
+ (data.workspaceIdentifier?.uri?.external
1362
+ ? uriToPath(data.workspaceIdentifier.uri.external)
1363
+ : data.workspaceUri
1364
+ ? uriToPath(data.workspaceUri)
1365
+ : 'Global');
793
1366
  sessions.push({
794
1367
  id: composerId,
795
1368
  index: 0,
796
1369
  title: data.name ?? data.title ?? null,
797
1370
  createdAt,
798
- lastUpdatedAt: data.updatedAt ? new Date(data.updatedAt) : createdAt,
1371
+ lastUpdatedAt: parseDateValue(data.lastUpdatedAt) ?? parseDateValue(data.updatedAt) ?? createdAt,
799
1372
  messageCount: bubbleCount.count,
800
1373
  workspaceId: 'global',
801
1374
  workspacePath: contractPath(workspacePath),
@@ -822,13 +1395,13 @@ export async function listGlobalSessions() {
822
1395
  /**
823
1396
  * Get a session from global storage by index
824
1397
  */
825
- export async function getGlobalSession(index) {
826
- const summaries = await listGlobalSessions();
1398
+ export async function getGlobalSession(index, customDataPath) {
1399
+ const summaries = await listGlobalSessions(customDataPath);
827
1400
  const summary = summaries.find((s) => s.index === index);
828
1401
  if (!summary) {
829
1402
  return null;
830
1403
  }
831
- const globalPath = getGlobalStoragePath();
1404
+ const globalPath = getGlobalStoragePath(customDataPath);
832
1405
  const dbPath = join(globalPath, 'state.vscdb');
833
1406
  let db = null;
834
1407
  try {
@@ -1605,19 +2178,8 @@ export async function findWorkspaceForSession(sessionId, customDataPath) {
1605
2178
  db.close();
1606
2179
  if (!result)
1607
2180
  continue;
1608
- // Parse the composerData - could be new format with allComposers or legacy format
1609
- const parsed = JSON.parse(result.data);
1610
- // Handle new format with allComposers array
1611
- let composers;
1612
- if ('allComposers' in parsed && Array.isArray(parsed.allComposers)) {
1613
- composers = parsed.allComposers;
1614
- }
1615
- else if (Array.isArray(parsed)) {
1616
- composers = parsed;
1617
- }
1618
- else {
1619
- continue;
1620
- }
2181
+ const composerRefs = extractComposerIdsFromData(result.bundle.composerData ?? result.data);
2182
+ const composers = composerRefs.map((ref) => ({ composerId: ref.composerId }));
1621
2183
  const found = composers.some((session) => session.composerId === sessionId);
1622
2184
  if (found) {
1623
2185
  return { workspace, dbPath: workspace.dbPath };
@@ -1642,6 +2204,39 @@ export async function findWorkspaceByPath(workspacePath, customDataPath) {
1642
2204
  return { workspace, dbPath: workspace.dbPath };
1643
2205
  }
1644
2206
  }
2207
+ // Fallback: include workspaces with zero sessions so migrations can target empty destinations.
2208
+ const basePath = getCursorDataPath(customDataPath);
2209
+ if (!existsSync(basePath)) {
2210
+ return null;
2211
+ }
2212
+ try {
2213
+ const entries = readdirSync(basePath, { withFileTypes: true });
2214
+ for (const entry of entries) {
2215
+ if (!entry.isDirectory())
2216
+ continue;
2217
+ const workspaceDir = join(basePath, entry.name);
2218
+ const dbPath = join(workspaceDir, 'state.vscdb');
2219
+ if (!existsSync(dbPath))
2220
+ continue;
2221
+ const workspacePathFromJson = readWorkspaceJson(workspaceDir);
2222
+ if (!workspacePathFromJson)
2223
+ continue;
2224
+ if (pathsEqual(workspacePathFromJson, normalizedPath)) {
2225
+ return {
2226
+ workspace: {
2227
+ id: entry.name,
2228
+ path: workspacePathFromJson,
2229
+ dbPath,
2230
+ sessionCount: 0,
2231
+ },
2232
+ dbPath,
2233
+ };
2234
+ }
2235
+ }
2236
+ }
2237
+ catch {
2238
+ return null;
2239
+ }
1645
2240
  return null;
1646
2241
  }
1647
2242
  /**
@@ -1660,8 +2255,38 @@ export function getComposerData(db) {
1660
2255
  // Check if new format with allComposers
1661
2256
  if (rawData && typeof rawData === 'object' && 'allComposers' in rawData) {
1662
2257
  const data = rawData;
2258
+ const selectedComposers = Array.isArray(data.selectedComposerIds)
2259
+ ? data.selectedComposerIds
2260
+ .filter((id) => typeof id === 'string' && id.trim().length > 0)
2261
+ .map((id) => ({ composerId: id }))
2262
+ : [];
2263
+ const allComposers = Array.isArray(data.allComposers) ? data.allComposers : [];
2264
+ const seenIds = new Set(allComposers
2265
+ .map((composer) => composer.composerId)
2266
+ .filter((id) => typeof id === 'string' && id.trim().length > 0));
2267
+ const missingSelectedComposers = selectedComposers.filter((composer) => {
2268
+ if (!composer.composerId || seenIds.has(composer.composerId)) {
2269
+ return false;
2270
+ }
2271
+ seenIds.add(composer.composerId);
2272
+ return true;
2273
+ });
1663
2274
  return {
1664
- composers: data.allComposers ?? [],
2275
+ composers: [...allComposers, ...missingSelectedComposers],
2276
+ rawData,
2277
+ isNewFormat: true,
2278
+ };
2279
+ }
2280
+ // Newer workspace shape: selectedComposerIds only (no allComposers list)
2281
+ if (rawData &&
2282
+ typeof rawData === 'object' &&
2283
+ 'selectedComposerIds' in rawData &&
2284
+ Array.isArray(rawData.selectedComposerIds)) {
2285
+ const selectedComposers = rawData.selectedComposerIds
2286
+ .filter((id) => typeof id === 'string' && id.trim().length > 0)
2287
+ .map((id) => ({ composerId: id }));
2288
+ return {
2289
+ composers: selectedComposers,
1665
2290
  rawData,
1666
2291
  isNewFormat: true,
1667
2292
  };
@@ -1689,10 +2314,53 @@ export function updateComposerData(db, composers, isNewFormat, originalRawData)
1689
2314
  if (isNewFormat) {
1690
2315
  // Preserve the original structure, just update allComposers
1691
2316
  if (originalRawData && typeof originalRawData === 'object') {
1692
- dataToWrite = { ...originalRawData, allComposers: composers };
2317
+ const original = originalRawData;
2318
+ const selectedOnly = Array.isArray(original['selectedComposerIds']) &&
2319
+ (!Array.isArray(original['allComposers']) || original['allComposers'].length === 0);
2320
+ // Only persist composers that carry real metadata. Synthetic `{ composerId }`
2321
+ // stubs (surfaced by getComposerData's union of selectedComposerIds) would
2322
+ // otherwise be written back and parse to phantom sessions on the next list.
2323
+ const nextData = {
2324
+ ...original,
2325
+ allComposers: composers.filter(isHydratedComposer),
2326
+ };
2327
+ if (Array.isArray(original['selectedComposerIds'])) {
2328
+ const composerIds = composers
2329
+ .map((composer) => composer.composerId)
2330
+ .filter((id) => typeof id === 'string' && id.trim().length > 0);
2331
+ const composerIdSet = new Set(composerIds);
2332
+ const originalSelected = original['selectedComposerIds'].filter((id) => typeof id === 'string' && id.trim().length > 0);
2333
+ const selectedComposerIds = selectedOnly
2334
+ ? composerIds
2335
+ : originalSelected.filter((id) => composerIdSet.has(id));
2336
+ nextData['selectedComposerIds'] = selectedComposerIds;
2337
+ if (Array.isArray(original['lastFocusedComposerIds'])) {
2338
+ const originalFocused = original['lastFocusedComposerIds'].filter((id) => typeof id === 'string' && id.trim().length > 0);
2339
+ // Keep the previously focused tab whenever it survives the change;
2340
+ // only fall back to the first surviving composer when it does not.
2341
+ const survivingFocused = originalFocused.filter((id) => composerIdSet.has(id));
2342
+ const focusedComposerIds = survivingFocused.length > 0
2343
+ ? survivingFocused
2344
+ : selectedOnly
2345
+ ? selectedComposerIds.slice(0, 1)
2346
+ : [];
2347
+ nextData['lastFocusedComposerIds'] = focusedComposerIds;
2348
+ }
2349
+ }
2350
+ dataToWrite = nextData;
1693
2351
  }
1694
2352
  else {
1695
- dataToWrite = { allComposers: composers };
2353
+ // No original shape to preserve: write only real composers, but keep any
2354
+ // id-only stubs referenced via selectedComposerIds so they remain listable.
2355
+ const hydrated = composers.filter(isHydratedComposer);
2356
+ const stubIds = composers
2357
+ .filter((composer) => !isHydratedComposer(composer))
2358
+ .map((composer) => composer.composerId)
2359
+ .filter((id) => typeof id === 'string' && id.trim().length > 0);
2360
+ dataToWrite =
2361
+ stubIds.length > 0
2362
+ ? { allComposers: hydrated, selectedComposerIds: stubIds }
2363
+ : { allComposers: hydrated };
1696
2364
  }
1697
2365
  }
1698
2366
  else {