spiracha 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -11,8 +11,17 @@ import type {
11
11
  CursorWorkspaceGroup,
12
12
  } from '../cursor-exporter-types';
13
13
  import { getCursorGlobalDbPath, resolveCursorUserDir } from '../cursor-exporter-types';
14
+ import { collectCursorThreadsForDeletion, isCursorRunning, pruneCursorThreads } from '../cursor-recovery';
15
+ import { getCursorTextBubblePhase, getFinalCursorAssistantTextBubbleIds } from '../cursor-transcript-phase';
14
16
  import { cleanInlineTitle } from '../shared';
15
- import { createDeepLinks, createTextMessage, finalizeMessages } from './adapter-helpers';
17
+ import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
18
+ import {
19
+ createConversationUiPath,
20
+ createDeepLinks,
21
+ createTextMessage,
22
+ finalizeMessages,
23
+ isWithinUpdatedWindow,
24
+ } from './adapter-helpers';
16
25
  import { selectConversationMessages } from './message-selector';
17
26
  import { getFirstConversationPathMatch } from './path-match';
18
27
  import type {
@@ -20,6 +29,7 @@ import type {
20
29
  ConversationDetail,
21
30
  ConversationMessage,
22
31
  ConversationPathMatch,
32
+ DeleteConversationOptions,
23
33
  GetConversationOptions,
24
34
  ListConversationsForPathOptions,
25
35
  } from './types';
@@ -29,21 +39,11 @@ const CURSOR_CONVERSATION_HYDRATION_CONCURRENCY = 4;
29
39
  const getUserDir = (options: { locations?: { cursorUserDir?: string } }) =>
30
40
  options.locations?.cursorUserDir ?? resolveCursorUserDir();
31
41
 
32
- const isWithinUpdatedWindow = (
33
- thread: CursorThreadSummary,
34
- options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
35
- ): boolean => {
36
- const updatedAtMs = thread.lastUpdatedAtMs ?? 0;
37
- if (options.updatedAfterMs !== undefined && updatedAtMs < options.updatedAfterMs) {
38
- return false;
39
- }
40
- if (options.updatedBeforeMs !== undefined && updatedAtMs > options.updatedBeforeMs) {
41
- return false;
42
- }
43
- return true;
44
- };
45
-
46
- const bubbleToMessages = (bubble: CursorBubble, order: number): ConversationMessage[] => {
42
+ const bubbleToMessages = (
43
+ bubble: CursorBubble,
44
+ finalAssistantTextBubbleIds: Set<string>,
45
+ order: number,
46
+ ): ConversationMessage[] => {
47
47
  const thinking = createTextMessage({
48
48
  createdAtMs: bubble.createdAtMs,
49
49
  id: `${bubble.bubbleId}:thinking`,
@@ -56,27 +56,41 @@ const bubbleToMessages = (bubble: CursorBubble, order: number): ConversationMess
56
56
  createdAtMs: bubble.createdAtMs,
57
57
  id: bubble.bubbleId,
58
58
  order,
59
- phase: bubble.kind === 'assistant' ? 'final_answer' : 'unknown',
59
+ phase: getCursorTextBubblePhase(bubble, finalAssistantTextBubbleIds) ?? 'unknown',
60
60
  role: bubble.kind === 'assistant' ? 'assistant' : bubble.kind === 'user' ? 'user' : 'unknown',
61
61
  text: bubble.text,
62
62
  });
63
- const tool = bubble.toolCall
63
+ const toolCall = bubble.toolCall
64
64
  ? createTextMessage({
65
65
  createdAtMs: bubble.createdAtMs,
66
- id: `${bubble.bubbleId}:tool`,
66
+ id: `${bubble.bubbleId}:tool_call`,
67
67
  metadata: { callId: bubble.toolCall.callId, status: bubble.toolCall.status },
68
68
  order,
69
- phase: bubble.toolCall.resultText ? 'tool_output' : 'tool_call',
69
+ phase: 'tool_call',
70
70
  role: 'tool',
71
- text: bubble.toolCall.resultText ?? bubble.toolCall.argumentsText ?? bubble.toolCall.name,
71
+ text: [bubble.toolCall.name, bubble.toolCall.argumentsText].filter(Boolean).join('\n'),
72
+ })
73
+ : [];
74
+ const toolOutput = bubble.toolCall
75
+ ? createTextMessage({
76
+ createdAtMs: bubble.createdAtMs,
77
+ id: `${bubble.bubbleId}:tool_output`,
78
+ metadata: { callId: bubble.toolCall.callId, status: bubble.toolCall.status },
79
+ order,
80
+ phase: 'tool_output',
81
+ role: 'tool',
82
+ text: bubble.toolCall.resultText,
72
83
  })
73
84
  : [];
74
85
 
75
- return [...thinking, ...text, ...tool];
86
+ return [...thinking, ...text, ...toolCall, ...toolOutput];
76
87
  };
77
88
 
78
89
  const transcriptToMessages = (transcript: CursorThreadTranscript) => {
79
- return finalizeMessages(transcript.bubbles.flatMap((bubble, order) => bubbleToMessages(bubble, order)));
90
+ const finalAssistantTextBubbleIds = getFinalCursorAssistantTextBubbleIds(transcript.bubbles);
91
+ return finalizeMessages(
92
+ transcript.bubbles.flatMap((bubble, order) => bubbleToMessages(bubble, finalAssistantTextBubbleIds, order)),
93
+ );
80
94
  };
81
95
 
82
96
  const getWorkspacePath = (group: CursorWorkspaceGroup) => {
@@ -90,8 +104,16 @@ const buildConversation = async (
90
104
  matches: ConversationPathMatch[],
91
105
  options: Pick<ListConversationsForPathOptions, 'includeMessages' | 'messageSelector'>,
92
106
  ): Promise<ConversationDetail> => {
107
+ const globalDbPath = getCursorGlobalDbPath(userDir);
93
108
  const transcript = options.includeMessages
94
- ? await readCursorThreadTranscriptWithAgentFiles(getCursorGlobalDbPath(userDir), thread.composerId, userDir)
109
+ ? await runWithTranscriptLoadLimit(
110
+ () => readCursorThreadTranscriptWithAgentFiles(globalDbPath, thread.composerId, userDir),
111
+ {
112
+ id: thread.composerId,
113
+ path: thread.transcriptDirs[0] ?? globalDbPath,
114
+ source: 'cursor-api',
115
+ },
116
+ )
95
117
  : null;
96
118
  const allMessages = transcript ? transcriptToMessages(transcript) : [];
97
119
  const messages = options.includeMessages
@@ -100,7 +122,11 @@ const buildConversation = async (
100
122
 
101
123
  return {
102
124
  createdAtMs: thread.createdAtMs,
103
- deepLinks: createDeepLinks('cursor', thread.composerId, `/cursor-threads/${thread.composerId}`),
125
+ deepLinks: createDeepLinks(
126
+ 'cursor',
127
+ thread.composerId,
128
+ createConversationUiPath('cursor-threads', thread.composerId),
129
+ ),
104
130
  id: thread.composerId,
105
131
  matches,
106
132
  messageCount: options.includeMessages ? allMessages.length : thread.bubbleCount,
@@ -109,7 +135,6 @@ const buildConversation = async (
109
135
  bubbleBytes: thread.bubbleBytes,
110
136
  bucketId: thread.bucketId,
111
137
  mode: thread.mode,
112
- transcriptDirs: thread.transcriptDirs,
113
138
  },
114
139
  source: 'cursor',
115
140
  title: cleanInlineTitle(thread.name),
@@ -121,7 +146,7 @@ const buildConversation = async (
121
146
 
122
147
  const listCursorConversationsForPath = async (options: ListConversationsForPathOptions) => {
123
148
  const userDir = getUserDir(options);
124
- const groups = await listCursorWorkspaceGroups(userDir, { updatedAfterMs: options.updatedAfterMs });
149
+ const groups = await listCursorWorkspaceGroups(userDir);
125
150
  const candidates: { group: CursorWorkspaceGroup; match: ConversationPathMatch; thread: CursorThreadSummary }[] = [];
126
151
 
127
152
  for (const group of groups) {
@@ -131,10 +156,9 @@ const listCursorConversationsForPath = async (options: ListConversationsForPathO
131
156
  }
132
157
  const threads = await listCursorThreadsForGroup(group, userDir, {
133
158
  includeTranscriptDirs: false,
134
- updatedAfterMs: options.updatedAfterMs,
135
159
  });
136
160
  for (const thread of threads) {
137
- if (!isWithinUpdatedWindow(thread, options)) {
161
+ if (!isWithinUpdatedWindow(thread.lastUpdatedAtMs, options)) {
138
162
  continue;
139
163
  }
140
164
 
@@ -164,7 +188,31 @@ const getCursorConversation = async (options: GetConversationOptions): Promise<C
164
188
  return null;
165
189
  };
166
190
 
191
+ export const deleteCursorConversation = async (
192
+ options: DeleteConversationOptions,
193
+ checkCursorRunning: () => Promise<boolean> = isCursorRunning,
194
+ ) => {
195
+ const userDir = getUserDir(options);
196
+ if (await checkCursorRunning()) {
197
+ throw new Error(
198
+ 'Quit Cursor before deleting. It rewrites chat history on exit, which can resurrect deleted threads.',
199
+ );
200
+ }
201
+
202
+ const threads = await collectCursorThreadsForDeletion([options.id], userDir);
203
+ if (threads.length === 0) {
204
+ return { deletedFiles: [], deletedIds: [] };
205
+ }
206
+ const deletedFiles = threads.flatMap((thread) => thread.transcriptDirs);
207
+ const result = await pruneCursorThreads(threads, true, userDir);
208
+ return {
209
+ deletedFiles,
210
+ deletedIds: result.composerIds,
211
+ };
212
+ };
213
+
167
214
  export const cursorConversationAdapter: ConversationAdapter = {
215
+ deleteConversation: deleteCursorConversation,
168
216
  getConversation: getCursorConversation,
169
217
  listConversationsForPath: listCursorConversationsForPath,
170
218
  source: 'cursor',
@@ -0,0 +1,223 @@
1
+ import { mapWithConcurrency } from '../concurrency';
2
+ import {
3
+ deleteGrokSession,
4
+ listGrokSessionTranscriptsForGroup,
5
+ listGrokWorkspaceGroups,
6
+ readGrokSessionTranscript,
7
+ resolveGrokSessionsDir,
8
+ } from '../grok-db';
9
+ import type {
10
+ GrokSessionSummary,
11
+ GrokSessionTranscript,
12
+ GrokTranscriptEntry,
13
+ GrokTranscriptPart,
14
+ } from '../grok-exporter-types';
15
+ import { getFinalGrokAssistantTextPartIds, getGrokTextPartPhase } from '../grok-transcript-phase';
16
+ import { cleanInlineTitle } from '../shared';
17
+ import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
18
+ import {
19
+ createConversationUiPath,
20
+ createDeepLinks,
21
+ createTextMessage,
22
+ finalizeMessages,
23
+ isWithinUpdatedWindow,
24
+ normalizeAssistantPhase,
25
+ normalizeRole,
26
+ } from './adapter-helpers';
27
+ import { selectConversationMessages } from './message-selector';
28
+ import { getConversationPathMatch } from './path-match';
29
+ import type {
30
+ ConversationAdapter,
31
+ ConversationDetail,
32
+ ConversationMessage,
33
+ ConversationPathMatch,
34
+ DeleteConversationOptions,
35
+ GetConversationOptions,
36
+ ListConversationsForPathOptions,
37
+ } from './types';
38
+
39
+ const GROK_CONVERSATION_HYDRATION_CONCURRENCY = 4;
40
+
41
+ const getSessionsDir = (options: { locations?: { grokSessionsDir?: string } }) =>
42
+ options.locations?.grokSessionsDir ?? resolveGrokSessionsDir();
43
+
44
+ const partToMessages = (
45
+ entry: GrokTranscriptEntry,
46
+ part: GrokTranscriptPart,
47
+ finalTextPartIds: Set<string>,
48
+ order: number,
49
+ ): ConversationMessage[] => {
50
+ if (part.type === 'text') {
51
+ return createTextMessage({
52
+ createdAtMs: entry.createdAtMs,
53
+ id: part.partId,
54
+ metadata: { modelFingerprint: entry.modelFingerprint, modelId: entry.modelId },
55
+ order,
56
+ phase: normalizeAssistantPhase(getGrokTextPartPhase(entry, part, finalTextPartIds), 'unknown'),
57
+ role: normalizeRole(entry.role),
58
+ text: part.text,
59
+ });
60
+ }
61
+
62
+ if (part.type === 'reasoning') {
63
+ return createTextMessage({
64
+ createdAtMs: entry.createdAtMs,
65
+ id: part.partId,
66
+ order,
67
+ phase: 'reasoning',
68
+ role: 'assistant',
69
+ text: part.text,
70
+ });
71
+ }
72
+
73
+ if (part.type === 'tool_call') {
74
+ return createTextMessage({
75
+ createdAtMs: entry.createdAtMs,
76
+ id: part.partId,
77
+ metadata: { toolCallId: part.toolCallId, toolName: part.toolName },
78
+ order,
79
+ phase: 'tool_call',
80
+ role: 'tool',
81
+ text: [part.toolName, part.argumentsText].filter(Boolean).join('\n'),
82
+ });
83
+ }
84
+
85
+ if (part.type === 'tool_result') {
86
+ return createTextMessage({
87
+ createdAtMs: entry.createdAtMs,
88
+ id: part.partId,
89
+ metadata: { toolCallId: part.toolCallId },
90
+ order,
91
+ phase: 'tool_output',
92
+ role: 'tool',
93
+ text: part.outputText,
94
+ });
95
+ }
96
+
97
+ return [];
98
+ };
99
+
100
+ const transcriptToMessages = (transcript: GrokSessionTranscript): ConversationMessage[] => {
101
+ const finalTextPartIds = getFinalGrokAssistantTextPartIds(transcript.entries);
102
+ return finalizeMessages(
103
+ transcript.entries.flatMap((entry, entryIndex) =>
104
+ entry.parts.flatMap((part, partIndex) =>
105
+ partToMessages(entry, part, finalTextPartIds, entryIndex + partIndex),
106
+ ),
107
+ ),
108
+ );
109
+ };
110
+
111
+ const buildConversation = async (
112
+ session: GrokSessionSummary,
113
+ sessionsDir: string,
114
+ matches: ConversationPathMatch[],
115
+ options: Pick<ListConversationsForPathOptions, 'includeMessages' | 'messageSelector'>,
116
+ loadedTranscript: GrokSessionTranscript | null = null,
117
+ ): Promise<ConversationDetail> => {
118
+ const transcript =
119
+ loadedTranscript ??
120
+ (options.includeMessages
121
+ ? await runWithTranscriptLoadLimit(
122
+ () => readGrokSessionTranscript(sessionsDir, session.sessionId, { includeRawPayloads: false }),
123
+ {
124
+ id: session.sessionId,
125
+ path: sessionsDir,
126
+ source: 'grok-api',
127
+ },
128
+ )
129
+ : null);
130
+ const allMessages = transcript ? transcriptToMessages(transcript) : [];
131
+ const messages = options.includeMessages
132
+ ? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
133
+ : [];
134
+
135
+ return {
136
+ createdAtMs: session.createdAtMs,
137
+ deepLinks: createDeepLinks(
138
+ 'grok',
139
+ session.sessionId,
140
+ createConversationUiPath('grok-sessions', session.sessionId),
141
+ ),
142
+ id: session.sessionId,
143
+ matches,
144
+ messageCount: options.includeMessages ? allMessages.length : session.messageCount,
145
+ messages,
146
+ metadata: {
147
+ agentName: session.agentName,
148
+ currentModelId: session.currentModelId,
149
+ gitBranch: session.gitBranch,
150
+ headCommit: session.headCommit,
151
+ modelLabel: session.modelLabel,
152
+ renderablePartCount: session.renderablePartCount,
153
+ sandboxProfile: session.sandboxProfile,
154
+ },
155
+ source: 'grok',
156
+ title: cleanInlineTitle(session.title),
157
+ updatedAtMs: session.lastActiveAtMs,
158
+ workspaceKey: session.workspaceKey,
159
+ workspacePath: session.worktree,
160
+ };
161
+ };
162
+
163
+ const listGrokConversationsForPath = async (options: ListConversationsForPathOptions) => {
164
+ const sessionsDir = getSessionsDir(options);
165
+ const groups = await listGrokWorkspaceGroups(sessionsDir);
166
+ const conversations: ConversationDetail[] = [];
167
+
168
+ for (const group of groups) {
169
+ const match = await getConversationPathMatch(options.cwd, group.worktree);
170
+ if (!match) {
171
+ continue;
172
+ }
173
+ const transcripts = (await listGrokSessionTranscriptsForGroup(group.key, sessionsDir)).filter((transcript) =>
174
+ isWithinUpdatedWindow(transcript.session.lastActiveAtMs, options),
175
+ );
176
+ conversations.push(
177
+ ...(await mapWithConcurrency(transcripts, GROK_CONVERSATION_HYDRATION_CONCURRENCY, (transcript) =>
178
+ buildConversation(transcript.session, sessionsDir, [match], options, transcript),
179
+ )),
180
+ );
181
+ }
182
+
183
+ return conversations;
184
+ };
185
+
186
+ const getGrokConversation = async (options: GetConversationOptions): Promise<ConversationDetail | null> => {
187
+ const sessionsDir = getSessionsDir(options);
188
+ const transcript = await runWithTranscriptLoadLimit(
189
+ () => readGrokSessionTranscript(sessionsDir, options.id, { includeRawPayloads: false }),
190
+ {
191
+ id: options.id,
192
+ path: sessionsDir,
193
+ source: 'grok-api',
194
+ },
195
+ );
196
+ return transcript
197
+ ? buildConversation(
198
+ transcript.session,
199
+ sessionsDir,
200
+ [],
201
+ {
202
+ includeMessages: true,
203
+ messageSelector: options.messageSelector ?? 'all',
204
+ },
205
+ transcript,
206
+ )
207
+ : null;
208
+ };
209
+
210
+ const deleteGrokConversation = async (options: DeleteConversationOptions) => {
211
+ const result = await deleteGrokSession(getSessionsDir(options), options.id);
212
+ return {
213
+ deletedFiles: result.deletedFiles,
214
+ deletedIds: result.deletedSessionIds,
215
+ };
216
+ };
217
+
218
+ export const grokConversationAdapter: ConversationAdapter = {
219
+ deleteConversation: deleteGrokConversation,
220
+ getConversation: getGrokConversation,
221
+ listConversationsForPath: listGrokConversationsForPath,
222
+ source: 'grok',
223
+ };
@@ -1,7 +1,9 @@
1
+ import { mapWithConcurrency } from '../concurrency';
1
2
  import { antigravityConversationAdapter } from './antigravity-adapter';
2
3
  import { claudeCodeConversationAdapter } from './claude-code-adapter';
3
4
  import { codexConversationAdapter } from './codex-adapter';
4
5
  import { cursorConversationAdapter } from './cursor-adapter';
6
+ import { grokConversationAdapter } from './grok-adapter';
5
7
  import { kiroConversationAdapter } from './kiro-adapter';
6
8
  import { selectConversationMessages } from './message-selector';
7
9
  import { opencodeConversationAdapter } from './opencode-adapter';
@@ -14,6 +16,11 @@ import {
14
16
  type ConversationPage,
15
17
  type ConversationSource,
16
18
  type ConversationSourceInfo,
19
+ type DeleteConversationItemResult,
20
+ type DeleteConversationOptions,
21
+ type DeleteConversationResult,
22
+ type DeleteConversationsOptions,
23
+ type DeleteConversationsResult,
17
24
  type GetConversationOptions,
18
25
  type ListConversationsForPathOptions,
19
26
  type ResolvedConversationRef,
@@ -27,6 +34,7 @@ export {
27
34
  type ConversationDataLocations,
28
35
  type ConversationDeepLinks,
29
36
  type ConversationDetail,
37
+ type ConversationIdSetOptions,
30
38
  type ConversationMessage,
31
39
  type ConversationMessagePhase,
32
40
  type ConversationMessageRole,
@@ -35,6 +43,13 @@ export {
35
43
  type ConversationPathMatch,
36
44
  type ConversationSource,
37
45
  type ConversationSourceInfo,
46
+ type ConversationZipDownload,
47
+ type DeleteConversationItemResult,
48
+ type DeleteConversationOptions,
49
+ type DeleteConversationResult,
50
+ type DeleteConversationsOptions,
51
+ type DeleteConversationsResult,
52
+ type ExportConversationsZipOptions,
38
53
  type GetConversationOptions,
39
54
  type ListConversationsForPathOptions,
40
55
  type ResolvedConversationRef,
@@ -45,6 +60,7 @@ const SOURCE_LABELS: Record<ConversationSource, string> = {
45
60
  'claude-code': 'Claude Code',
46
61
  codex: 'Codex',
47
62
  cursor: 'Cursor',
63
+ grok: 'Grok',
48
64
  kiro: 'Kiro',
49
65
  opencode: 'OpenCode',
50
66
  qoder: 'Qoder',
@@ -64,6 +80,7 @@ const ADAPTERS: Partial<Record<ConversationSource, ConversationAdapter>> = {
64
80
  'claude-code': claudeCodeConversationAdapter,
65
81
  codex: codexConversationAdapter,
66
82
  cursor: cursorConversationAdapter,
83
+ grok: grokConversationAdapter,
67
84
  kiro: kiroConversationAdapter,
68
85
  opencode: opencodeConversationAdapter,
69
86
  qoder: qoderConversationAdapter,
@@ -71,13 +88,23 @@ const ADAPTERS: Partial<Record<ConversationSource, ConversationAdapter>> = {
71
88
 
72
89
  const MAX_LIMIT = 200;
73
90
  const DEFAULT_LIMIT = 100;
91
+ const DELETE_CONCURRENCY_BY_SOURCE: Record<ConversationSource, number> = {
92
+ antigravity: 1,
93
+ 'claude-code': 4,
94
+ codex: 1,
95
+ cursor: 1,
96
+ grok: 1,
97
+ kiro: 1,
98
+ opencode: 2,
99
+ qoder: 1,
100
+ };
74
101
 
75
102
  const getEnabledSources = (sources: ListConversationsForPathOptions['sources']): ConversationSource[] => {
76
103
  if (!sources || sources === 'all') {
77
104
  return SOURCE_INFOS.map((sourceInfo) => sourceInfo.source);
78
105
  }
79
106
 
80
- return sources;
107
+ return [...new Set(sources)];
81
108
  };
82
109
 
83
110
  const isAllSourcesRequest = (sources: ListConversationsForPathOptions['sources']) => !sources || sources === 'all';
@@ -91,8 +118,16 @@ const decodeCursor = (cursor: string | null | undefined) => {
91
118
  return 0;
92
119
  }
93
120
 
94
- const parsed = Number.parseInt(Buffer.from(cursor, 'base64url').toString('utf8'), 10);
95
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
121
+ const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
122
+ if (!/^\d+$/u.test(decoded)) {
123
+ throw new Error('Invalid conversation pagination cursor.');
124
+ }
125
+
126
+ const parsed = Number(decoded);
127
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
128
+ throw new Error('Invalid conversation pagination cursor.');
129
+ }
130
+ return parsed;
96
131
  };
97
132
 
98
133
  const encodeCursor = (offset: number) => Buffer.from(String(offset), 'utf8').toString('base64url');
@@ -130,7 +165,7 @@ const sortConversations = (conversations: Awaited<ReturnType<ConversationAdapter
130
165
  );
131
166
  };
132
167
 
133
- export const listConversationSources = async (): Promise<ConversationSourceInfo[]> => SOURCE_INFOS;
168
+ export const listConversationSources = async (): Promise<ConversationSourceInfo[]> => [...SOURCE_INFOS];
134
169
 
135
170
  const listSourceConversationsForPath = async (
136
171
  source: ConversationSource,
@@ -148,6 +183,10 @@ const listSourceConversationsForPath = async (
148
183
  if (!ignoreSourceFailures) {
149
184
  throw error;
150
185
  }
186
+ console.warn(`[spiracha:conversation-data] skipped ${source} during all-source collection`, {
187
+ error: error instanceof Error ? error.message : String(error),
188
+ source,
189
+ });
151
190
  return [];
152
191
  }
153
192
  };
@@ -181,10 +220,56 @@ export const getConversation = async (options: GetConversationOptions) => {
181
220
  return getAdapter(options.source)?.getConversation(options) ?? null;
182
221
  };
183
222
 
223
+ export const deleteConversation = async (
224
+ options: DeleteConversationOptions,
225
+ ): Promise<DeleteConversationResult | null> => {
226
+ return (await getAdapter(options.source)?.deleteConversation?.(options)) ?? null;
227
+ };
228
+
229
+ export const deleteConversations = async (
230
+ options: DeleteConversationsOptions,
231
+ ): Promise<DeleteConversationsResult | null> => {
232
+ const adapter = getAdapter(options.source);
233
+ if (!adapter?.deleteConversation) {
234
+ return null;
235
+ }
236
+ const deleteAdapterConversation = adapter.deleteConversation;
237
+
238
+ const rawResults = await mapWithConcurrency(
239
+ options.ids,
240
+ DELETE_CONCURRENCY_BY_SOURCE[options.source],
241
+ async (id) => ({
242
+ id,
243
+ result: await deleteAdapterConversation({
244
+ id,
245
+ locations: options.locations,
246
+ source: options.source,
247
+ }),
248
+ }),
249
+ );
250
+ const deletedIdSet = new Set(rawResults.flatMap(({ result }) => result.deletedIds));
251
+ const results: DeleteConversationItemResult[] = rawResults.map(({ id, result }) => ({
252
+ deleted: result.deletedIds.length > 0 || deletedIdSet.has(id),
253
+ deletedFiles: result.deletedFiles,
254
+ deletedIds: result.deletedIds,
255
+ id,
256
+ }));
257
+
258
+ return {
259
+ deletedFiles: [...new Set(results.flatMap((result) => result.deletedFiles))],
260
+ deletedIds: [...deletedIdSet],
261
+ missingIds: results.filter((result) => !result.deleted).map((result) => result.id),
262
+ results,
263
+ };
264
+ };
265
+
184
266
  const sourceFromSessionRoute = (segment: string): ConversationSource | null => {
185
267
  if (segment === 'claude-code-sessions') {
186
268
  return 'claude-code';
187
269
  }
270
+ if (segment === 'grok-sessions') {
271
+ return 'grok';
272
+ }
188
273
  if (segment === 'kiro-sessions') {
189
274
  return 'kiro';
190
275
  }
@@ -241,14 +326,21 @@ const refFromPathSegmentAt = (segments: string[], index: number): ResolvedConver
241
326
  };
242
327
 
243
328
  const refFromPathSegments = (segments: string[]): ResolvedConversationRef | null => {
244
- for (let index = 0; index < segments.length - 1; index += 1) {
245
- const ref = refFromPathSegmentAt(segments, index);
246
- if (ref) {
247
- return ref;
248
- }
329
+ if (
330
+ segments[0] === 'api' &&
331
+ segments[1] === 'v1' &&
332
+ segments[2] === 'conversations' &&
333
+ (segments.length === 5 || (segments.length === 6 && segments[5] === 'export'))
334
+ ) {
335
+ return refFromPathSegmentAt(segments, 2);
249
336
  }
250
337
 
251
- return null;
338
+ if (segments[0] === 'app') {
339
+ return refFromPathSegments(segments.slice(1));
340
+ }
341
+
342
+ const expectedLength = segments[0] === 'conversations' ? 3 : 2;
343
+ return segments.length === expectedLength ? refFromPathSegmentAt(segments, 0) : null;
252
344
  };
253
345
 
254
346
  const parseUrlRef = (ref: string): ResolvedConversationRef | null => {
@@ -287,7 +379,9 @@ export const renderConversationMarkdown = (
287
379
  messages: ConversationMessage[];
288
380
  title: string | null;
289
381
  },
290
- options: { messageSelector?: ConversationMessageSelector } = {},
382
+ options: {
383
+ messageSelector?: ConversationMessageSelector;
384
+ } = {},
291
385
  ) => {
292
386
  const selectedMessages = options.messageSelector
293
387
  ? selectConversationMessages(conversation.messages, options.messageSelector)