spiracha 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -1,7 +1,9 @@
1
+ import type { AntigravityConversation } from '@spiracha/lib/antigravity-exporter-types';
1
2
  import { buildConversationExportBaseName } from '@spiracha/lib/ui-export-archive';
2
3
  import { createServerFn } from '@tanstack/react-start';
3
4
  import { z } from 'zod';
4
5
  import { canExportAntigravityConversation, isAntigravityConversationLocked } from './antigravity-conversation-state';
6
+ import { runDeleteBatch } from './delete-batch';
5
7
 
6
8
  const workspaceSchema = z.object({
7
9
  workspaceKey: z.string().min(1),
@@ -13,10 +15,18 @@ const conversationSchema = z.object({
13
15
 
14
16
  const exportSchema = z.object({
15
17
  conversationId: z.string().min(1),
18
+ includeCommentary: z.boolean().default(false),
19
+ includeMetadata: z.boolean().default(true),
20
+ includeTools: z.boolean().default(true),
21
+ outputFormat: z.enum(['md', 'txt']).default('md'),
22
+ zipArchive: z.boolean().default(false),
16
23
  });
17
24
 
18
25
  const exportConversationsSchema = z.object({
19
26
  conversationIds: z.array(z.string().min(1)).min(1),
27
+ includeCommentary: z.boolean().default(false),
28
+ includeMetadata: z.boolean().default(true),
29
+ includeTools: z.boolean().default(true),
20
30
  outputFormat: z.enum(['md', 'txt']).default('md'),
21
31
  zipArchive: z.boolean().default(true),
22
32
  });
@@ -111,11 +121,22 @@ export const loadAntigravityConversationDetail = async (conversationId: string)
111
121
  };
112
122
  };
113
123
 
114
- export const loadAntigravityConversationExport = async (conversationId: string) => {
124
+ type AntigravityConversationExportOptions = {
125
+ includeCommentary?: boolean;
126
+ includeMetadata?: boolean;
127
+ includeTools?: boolean;
128
+ outputFormat?: 'md' | 'txt';
129
+ };
130
+
131
+ export const loadAntigravityConversationExport = async (
132
+ conversationId: string,
133
+ options: AntigravityConversationExportOptions = {},
134
+ loadedConversation?: AntigravityConversation,
135
+ ) => {
115
136
  const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
116
137
  const { renderAntigravityConversationMarkdown } = await import('@spiracha/lib/antigravity-db');
117
138
  const { getCachedAntigravityKeychainSecret } = await import('@spiracha/lib/antigravity-keychain');
118
- const conversation = await findAntigravityConversationById(conversationId);
139
+ const conversation = loadedConversation ?? (await findAntigravityConversationById(conversationId));
119
140
  const keychainSecret = getCachedAntigravityKeychainSecret();
120
141
  const hasKeychainSecret = Boolean(keychainSecret);
121
142
 
@@ -130,7 +151,11 @@ export const loadAntigravityConversationExport = async (conversationId: string)
130
151
  const content = await runWithTranscriptLoadLimit(
131
152
  () =>
132
153
  renderAntigravityConversationMarkdown(conversation, {
154
+ includeCommentary: options.includeCommentary,
155
+ includeMetadata: options.includeMetadata,
156
+ includeTools: options.includeTools,
133
157
  keychainSecret,
158
+ outputFormat: options.outputFormat,
134
159
  }),
135
160
  {
136
161
  id: conversation.conversationId,
@@ -152,7 +177,7 @@ export const loadAntigravityConversationExport = async (conversationId: string)
152
177
  updatedAtMs: conversation.lastUpdatedAtMs ?? conversation.conversationMtimeMs,
153
178
  },
154
179
  'antigravity-conversation',
155
- )}.md`,
180
+ )}.${options.outputFormat ?? 'md'}`,
156
181
  };
157
182
  };
158
183
 
@@ -171,8 +196,8 @@ export const deleteAntigravityConversationsById = async (conversationIds: string
171
196
  const { deleteAntigravityConversation } = await import('@spiracha/lib/antigravity-db');
172
197
  const { resolveAntigravityRoots } = await import('@spiracha/lib/antigravity-exporter-types');
173
198
  const roots = resolveAntigravityRoots();
174
- const results = await Promise.all(
175
- conversationIds.map((conversationId) => deleteAntigravityConversation(roots, conversationId)),
199
+ const results = await runDeleteBatch(conversationIds, (conversationId) =>
200
+ deleteAntigravityConversation(roots, conversationId),
176
201
  );
177
202
  const deletedConversationIds = results.flatMap((result) => result.deletedConversationIds);
178
203
  if (deletedConversationIds.length === 0) {
@@ -192,7 +217,7 @@ export const getAntigravityConversationDetailFn = createServerFn({ method: 'GET'
192
217
  });
193
218
 
194
219
  export const exportAntigravityArtifactsFn = createServerFn({ method: 'POST' })
195
- .validator(exportSchema)
220
+ .validator(conversationSchema)
196
221
  .handler(async ({ data }) => {
197
222
  const { renderAntigravityArtifactsMarkdown } = await import('@spiracha/lib/antigravity-db');
198
223
  const conversation = await findAntigravityConversationById(data.conversationId);
@@ -211,22 +236,50 @@ export const exportAntigravityArtifactsFn = createServerFn({ method: 'POST' })
211
236
  export const exportAntigravityConversationFn = createServerFn({ method: 'POST' })
212
237
  .validator(exportSchema)
213
238
  .handler(async ({ data }) => {
214
- return loadAntigravityConversationExport(data.conversationId);
239
+ return exportAntigravityConversations({
240
+ conversationIds: [data.conversationId],
241
+ includeCommentary: data.includeCommentary,
242
+ includeMetadata: data.includeMetadata,
243
+ includeTools: data.includeTools,
244
+ outputFormat: data.outputFormat,
245
+ zipArchive: data.zipArchive,
246
+ });
215
247
  });
216
248
 
217
- export const exportAntigravityConversations = async (data: z.infer<typeof exportConversationsSchema>) => {
249
+ export const exportAntigravityConversations = async (input: z.input<typeof exportConversationsSchema>) => {
250
+ const data = exportConversationsSchema.parse(input);
218
251
  const { renderSourceSessionsDownload } = await import('./source-session-export-server');
219
- const { listAntigravityWorkspaceGroups } = await import('@spiracha/lib/antigravity-db');
220
- const groups = await listAntigravityWorkspaceGroups();
221
- const groupsByKey = new Map(groups.map((group) => [group.key, group]));
252
+ const { listAntigravityConversations } = await import('@spiracha/lib/antigravity-db');
253
+ const { resolveAntigravityProjectNames } = await import('@spiracha/lib/antigravity-projects');
254
+ const conversations = await listAntigravityConversations();
255
+ const conversationsById = new Map(conversations.map((conversation) => [conversation.conversationId, conversation]));
256
+ const selectedConversations = data.conversationIds.map((conversationId) => {
257
+ const conversation = conversationsById.get(conversationId);
258
+ if (!conversation) {
259
+ throw new Error(`Antigravity conversation not found: ${conversationId}`);
260
+ }
261
+ return conversation;
262
+ });
263
+ const projectNames = await resolveAntigravityProjectNames(
264
+ selectedConversations.flatMap((conversation) => (conversation.projectId ? [conversation.projectId] : [])),
265
+ );
222
266
  const entries = await Promise.all(
223
- data.conversationIds.map(async (conversationId) => {
224
- const result = await loadAntigravityConversationExport(conversationId);
267
+ selectedConversations.map(async (loadedConversation) => {
268
+ const conversationId = loadedConversation.conversationId;
269
+ const result = await loadAntigravityConversationExport(
270
+ conversationId,
271
+ {
272
+ includeCommentary: data.includeCommentary,
273
+ includeMetadata: data.includeMetadata,
274
+ includeTools: data.includeTools,
275
+ outputFormat: data.outputFormat,
276
+ },
277
+ loadedConversation,
278
+ );
225
279
  const conversation = result.conversation;
226
- const projectGroup = conversation.projectId
227
- ? groupsByKey.get(`project:${conversation.projectId}`)
228
- : undefined;
229
- const exportCwd = projectGroup?.label ?? conversation.workspaceFolder;
280
+ const exportCwd =
281
+ (conversation.projectId ? projectNames.get(conversation.projectId) : null) ??
282
+ conversation.workspaceFolder;
230
283
  const updatedAtMs = conversation.lastUpdatedAtMs ?? conversation.conversationMtimeMs;
231
284
  return {
232
285
  content: result.content,
@@ -1,5 +1,7 @@
1
+ import { getFinalAntigravityAssistantSequences } from '@spiracha/lib/antigravity-transcript-phase';
1
2
  import type { ThreadEvent, ThreadTranscriptStats } from '@spiracha/lib/codex-browser-types';
2
3
  import type { JsonValue } from '@spiracha/lib/shared';
4
+ import { getThreadTranscriptStats } from './thread-transcript-stats';
3
5
 
4
6
  type MarkdownSection = {
5
7
  body: string;
@@ -23,6 +25,18 @@ const TIMESTAMP_PATTERN = /^_Timestamp:\s*(.+?)_$/u;
23
25
  const TOOL_HEADING_PATTERN = /^tool:\s*(.+)$/iu;
24
26
  const CONTROL_SUBHEADING_PATTERN = /^###\s+(Thinking|Tool Calls)\s*$/iu;
25
27
 
28
+ type MarkdownFence = { character: string; length: number };
29
+
30
+ const updateMarkdownFence = (fence: MarkdownFence | null, token: string | null): MarkdownFence | null => {
31
+ if (!token) {
32
+ return fence;
33
+ }
34
+ if (!fence) {
35
+ return { character: token[0]!, length: token.length };
36
+ }
37
+ return token[0] === fence.character && token.length >= fence.length ? null : fence;
38
+ };
39
+
26
40
  const splitMarkdownSections = (markdown: string | null): MarkdownSection[] => {
27
41
  if (!markdown?.trim()) {
28
42
  return [];
@@ -32,6 +46,7 @@ const splitMarkdownSections = (markdown: string | null): MarkdownSection[] => {
32
46
  const lines = markdown.split(/\r?\n/u);
33
47
  let currentHeading: string | null = null;
34
48
  let currentLines: string[] = [];
49
+ let fence: MarkdownFence | null = null;
35
50
 
36
51
  const flush = () => {
37
52
  if (!currentHeading) {
@@ -41,12 +56,13 @@ const splitMarkdownSections = (markdown: string | null): MarkdownSection[] => {
41
56
  sections.push({
42
57
  body: currentLines.join('\n').trim(),
43
58
  heading: currentHeading,
44
- sequence: sections.length * 10,
59
+ sequence: sections.length * 1000,
45
60
  });
46
61
  };
47
62
 
48
63
  for (const line of lines) {
49
- const heading = HEADING_PATTERN.exec(line);
64
+ const fenceToken = /^\s*(`{3,}|~{3,})/u.exec(line)?.[1] ?? null;
65
+ const heading = fence === null && fenceToken === null ? HEADING_PATTERN.exec(line) : null;
50
66
  if (heading) {
51
67
  flush();
52
68
  currentHeading = heading[1]!.trim();
@@ -57,6 +73,8 @@ const splitMarkdownSections = (markdown: string | null): MarkdownSection[] => {
57
73
  if (currentHeading) {
58
74
  currentLines.push(line);
59
75
  }
76
+
77
+ fence = updateMarkdownFence(fence, fenceToken);
60
78
  }
61
79
 
62
80
  flush();
@@ -195,38 +213,37 @@ const buildToolOutputEvent = (
195
213
  });
196
214
 
197
215
  const getFinalAssistantSectionSequences = (sections: MarkdownSection[]): Set<number> => {
198
- const finalAssistantSectionSequences = new Set<number>();
199
- let currentAssistantContentSequence: number | null = null;
200
-
201
- const flushAssistantRun = () => {
202
- if (currentAssistantContentSequence !== null) {
203
- finalAssistantSectionSequences.add(currentAssistantContentSequence);
204
- }
205
- currentAssistantContentSequence = null;
206
- };
207
-
208
- for (const section of sections) {
216
+ const items = sections.map((section) => {
209
217
  const heading = section.heading.toLowerCase();
210
218
  if (heading === 'user') {
211
- flushAssistantRun();
212
- continue;
219
+ return {
220
+ hasContent: Boolean(section.body),
221
+ hasToolCalls: false,
222
+ role: 'user' as const,
223
+ sequence: section.sequence,
224
+ };
213
225
  }
214
226
 
215
- if (heading !== 'assistant') {
216
- continue;
227
+ if (heading === 'assistant') {
228
+ const { body } = extractTimestamp(section.body);
229
+ const parsed = parseAssistantSection(body);
230
+ return {
231
+ hasContent: Boolean(parsed.content),
232
+ hasToolCalls: parsed.toolCalls.length > 0,
233
+ role: 'assistant' as const,
234
+ sequence: section.sequence,
235
+ };
217
236
  }
218
237
 
219
- const { body } = extractTimestamp(section.body);
220
- const parsed = parseAssistantSection(body);
221
- if (parsed.toolCalls.length > 0) {
222
- currentAssistantContentSequence = null;
223
- } else if (parsed.content) {
224
- currentAssistantContentSequence = section.sequence;
225
- }
226
- }
238
+ return {
239
+ hasContent: Boolean(section.body),
240
+ hasToolCalls: false,
241
+ role: 'other' as const,
242
+ sequence: section.sequence,
243
+ };
244
+ });
227
245
 
228
- flushAssistantRun();
229
- return finalAssistantSectionSequences;
246
+ return getFinalAntigravityAssistantSequences(items);
230
247
  };
231
248
 
232
249
  const textSectionToEvents = (
@@ -298,46 +315,5 @@ export const antigravityMarkdownToThreadEvents = (markdown: string | null): Thre
298
315
  return sections.flatMap((section) => sectionToEvents(section, finalAssistantSectionSequences));
299
316
  };
300
317
 
301
- const updateMessageStats = (stats: ThreadTranscriptStats, event: Extract<ThreadEvent, { kind: 'message' }>) => {
302
- stats.messageCount += 1;
303
- if (event.role === 'assistant') {
304
- stats.assistantMessageCount += 1;
305
- }
306
- if (event.role === 'user') {
307
- stats.userMessageCount += 1;
308
- }
309
- if (event.phase === 'commentary') {
310
- stats.commentaryCount += 1;
311
- }
312
- if (event.phase === 'final_answer') {
313
- stats.finalAnswerCount += 1;
314
- }
315
- };
316
-
317
- export const getAntigravityThreadTranscriptStats = (events: ThreadEvent[]): ThreadTranscriptStats => {
318
- const stats: ThreadTranscriptStats = {
319
- assistantMessageCount: 0,
320
- commentaryCount: 0,
321
- execCommandCount: 0,
322
- finalAnswerCount: 0,
323
- messageCount: 0,
324
- toolCallCount: 0,
325
- toolOutputCount: 0,
326
- userMessageCount: 0,
327
- webSearchEventCount: 0,
328
- };
329
-
330
- for (const event of events) {
331
- if (event.kind === 'message') {
332
- updateMessageStats(stats, event);
333
- }
334
- if (event.kind === 'tool_call') {
335
- stats.toolCallCount += 1;
336
- }
337
- if (event.kind === 'tool_output') {
338
- stats.toolOutputCount += 1;
339
- }
340
- }
341
-
342
- return stats;
343
- };
318
+ export const getAntigravityThreadTranscriptStats = (events: ThreadEvent[]): ThreadTranscriptStats =>
319
+ getThreadTranscriptStats(events);
@@ -1,5 +1,6 @@
1
1
  import { createServerFn } from '@tanstack/react-start';
2
2
  import { z } from 'zod';
3
+ import { requireDeletedItems, runDeleteBatch } from './delete-batch';
3
4
  import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
4
5
 
5
6
  const LARGE_CLAUDE_CODE_SESSION_SIZE_BYTES = 8 * 1024 * 1024;
@@ -56,7 +57,9 @@ const loadClaudeCodeSessionTranscript = async (sessionId: string) => {
56
57
  const projectsDir = resolveClaudeCodeProjectsDir();
57
58
  return runWithTranscriptLoadLimit(
58
59
  async () => {
59
- const transcript = await readClaudeCodeSessionTranscript(projectsDir, sessionId);
60
+ const transcript = await readClaudeCodeSessionTranscript(projectsDir, sessionId, {
61
+ includeRawPayloads: false,
62
+ });
60
63
  if (!transcript) {
61
64
  throw new Error(`Claude Code session not found: ${sessionId}`);
62
65
  }
@@ -225,7 +228,9 @@ export const deleteClaudeCodeSessionFn = createServerFn({ method: 'POST' })
225
228
  .validator(sessionSchema)
226
229
  .handler(async ({ data }) => {
227
230
  const { deleteClaudeCodeSession, resolveClaudeCodeProjectsDir } = await import('@spiracha/lib/claude-code-db');
228
- return deleteClaudeCodeSession(resolveClaudeCodeProjectsDir(), data.sessionId);
231
+ const result = await deleteClaudeCodeSession(resolveClaudeCodeProjectsDir(), data.sessionId);
232
+ requireDeletedItems(result.deletedSessionIds, 'Claude Code session', data.sessionId);
233
+ return result;
229
234
  });
230
235
 
231
236
  export const deleteClaudeCodeSessionsFn = createServerFn({ method: 'POST' })
@@ -233,8 +238,13 @@ export const deleteClaudeCodeSessionsFn = createServerFn({ method: 'POST' })
233
238
  .handler(async ({ data }) => {
234
239
  const { deleteClaudeCodeSession, resolveClaudeCodeProjectsDir } = await import('@spiracha/lib/claude-code-db');
235
240
  const projectsDir = resolveClaudeCodeProjectsDir();
236
- const results = await Promise.all(
237
- data.sessionIds.map((sessionId) => deleteClaudeCodeSession(projectsDir, sessionId)),
241
+ const results = await runDeleteBatch(data.sessionIds, (sessionId) =>
242
+ deleteClaudeCodeSession(projectsDir, sessionId),
243
+ );
244
+ requireDeletedItems(
245
+ results.flatMap((result) => result.deletedSessionIds),
246
+ 'Claude Code sessions',
247
+ 'batch',
238
248
  );
239
249
  return {
240
250
  deletedFiles: [...new Set(results.flatMap((result) => result.deletedFiles))],
@@ -49,10 +49,10 @@ export const projectThreadsQueryOptions = (project: string) =>
49
49
  retryDelay,
50
50
  });
51
51
 
52
- export const threadSnapshotQueryOptions = (threadId: string, filters?: ThreadTranscriptFilters) =>
52
+ export const threadSnapshotQueryOptions = (threadId: string) =>
53
53
  queryOptions({
54
- queryFn: () => getThreadSnapshotFn({ data: { filters, threadId } }),
55
- queryKey: ['thread', threadId, filters ?? 'all'],
54
+ queryFn: () => getThreadSnapshotFn({ data: { threadId } }),
55
+ queryKey: ['thread', threadId],
56
56
  retry: retrySqliteQuery,
57
57
  retryDelay,
58
58
  });
@@ -22,7 +22,7 @@ const transcriptFiltersSchema = z.object({
22
22
  showUserMessages: z.boolean(),
23
23
  });
24
24
 
25
- const threadSnapshotSchema = z.object({
25
+ const threadPreviewSchema = z.object({
26
26
  filters: transcriptFiltersSchema.optional(),
27
27
  threadId: z.string().min(1),
28
28
  });
@@ -101,7 +101,7 @@ export const listProjectThreadsFn = createServerFn({ method: 'GET' })
101
101
  });
102
102
 
103
103
  export const getThreadSnapshotFn = createServerFn({ method: 'GET' })
104
- .validator(threadSnapshotSchema)
104
+ .validator(threadSchema)
105
105
  .handler(async ({ data }) => {
106
106
  const startedAt = Date.now();
107
107
  const [{ getThreadBrowseData }, { getThreadRolloutLoadState }] = await Promise.all([
@@ -197,7 +197,7 @@ export const loadThreadTranscript = async (threadId: string) => {
197
197
  };
198
198
 
199
199
  export const getThreadTranscriptPreviewFn = createServerFn({ method: 'GET' })
200
- .validator(threadSnapshotSchema)
200
+ .validator(threadPreviewSchema)
201
201
  .handler(async ({ data }) => {
202
202
  return loadThreadTranscriptPreview(data.threadId, data.filters);
203
203
  });
@@ -1,25 +1,18 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import {
6
- buildBatchExportBaseName,
7
- buildConversationExportBaseName,
8
- getExportMimeType,
9
- resolveUniqueExportFileBaseName,
10
- sanitizeExportFileName,
11
- zipExportDirectory,
12
- } from '@spiracha/lib/ui-export-archive';
13
- import { buildUiExportDownloadUrl, ensureUiExportDir } from '@spiracha/lib/ui-export-files';
1
+ import { isSafeCursorComposerId } from '@spiracha/lib/cursor-id';
14
2
  import { createServerFn } from '@tanstack/react-start';
15
3
  import { z } from 'zod';
4
+ import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
16
5
 
17
6
  const workspaceSchema = z.object({
18
7
  workspaceKey: z.string().min(1),
19
8
  });
20
9
 
10
+ const composerIdSchema = z.string().refine(isSafeCursorComposerId, {
11
+ message: 'Invalid Cursor composer id.',
12
+ });
13
+
21
14
  const threadSchema = z.object({
22
- composerId: z.string().min(1),
15
+ composerId: composerIdSchema,
23
16
  });
24
17
 
25
18
  const recoverSchema = z.object({
@@ -28,7 +21,7 @@ const recoverSchema = z.object({
28
21
  });
29
22
 
30
23
  const exportSchema = z.object({
31
- composerId: z.string().min(1),
24
+ composerId: composerIdSchema,
32
25
  includeCommentary: z.boolean().default(true),
33
26
  includeMetadata: z.boolean().default(true),
34
27
  includeTools: z.boolean().default(true),
@@ -37,7 +30,7 @@ const exportSchema = z.object({
37
30
  });
38
31
 
39
32
  const exportThreadsSchema = z.object({
40
- composerIds: z.array(z.string().min(1)).min(1),
33
+ composerIds: z.array(composerIdSchema).min(1),
41
34
  includeCommentary: z.boolean().default(true),
42
35
  includeMetadata: z.boolean().default(true),
43
36
  includeTools: z.boolean().default(true),
@@ -46,7 +39,7 @@ const exportThreadsSchema = z.object({
46
39
  });
47
40
 
48
41
  const deleteThreadsSchema = z.object({
49
- composerIds: z.array(z.string().min(1)).min(1),
42
+ composerIds: z.array(composerIdSchema).min(1),
50
43
  });
51
44
 
52
45
  const ensureCursorClosedForWrite = async () => {
@@ -69,48 +62,29 @@ const findGroupByKey = async (workspaceKey: string) => {
69
62
  return group;
70
63
  };
71
64
 
72
- const toSafeExportName = (value: string) => {
73
- return sanitizeExportFileName(value) || 'cursor-thread';
74
- };
75
-
76
65
  const renderCursorZipDownload = async (
77
- rendered: Array<{ composerId: string; content: string; cwd: string | null; updatedAtMs: number | null }>,
66
+ rendered: Array<{
67
+ composerId: string;
68
+ content: string;
69
+ cwd: string | null;
70
+ fileBaseName: string;
71
+ updatedAtMs: number | null;
72
+ }>,
78
73
  outputFormat: 'md' | 'txt',
79
74
  ) => {
80
- const exportDir = await ensureUiExportDir();
81
- const exportBaseName =
82
- rendered.length === 1
83
- ? buildConversationExportBaseName(
84
- {
85
- cwd: rendered[0]!.cwd,
86
- id: rendered[0]!.composerId,
87
- updatedAtMs: rendered[0]!.updatedAtMs,
88
- },
89
- 'cursor-thread',
90
- )
91
- : buildBatchExportBaseName(rendered, 'cursor');
92
- const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${exportBaseName}-`));
93
- const zipPath = path.join(exportDir, `${exportBaseName}-${randomUUID()}.zip`);
94
- const usedBaseNames = new Map<string, number>();
95
-
96
- try {
97
- for (const entry of rendered) {
98
- const baseName = toSafeExportName(entry.composerId);
99
- const fileBaseName = resolveUniqueExportFileBaseName(baseName, usedBaseNames);
100
- await Bun.write(path.join(workspaceDir, `${fileBaseName}.${outputFormat}`), entry.content);
101
- }
102
-
103
- await zipExportDirectory(workspaceDir, zipPath);
104
- } finally {
105
- await rm(workspaceDir, { force: true, recursive: true });
106
- }
107
-
108
- return {
109
- downloadUrl: buildUiExportDownloadUrl(zipPath),
110
- fileName: `${exportBaseName}.zip`,
111
- mimeType: 'application/zip',
112
- mode: 'download_url' as const,
113
- };
75
+ return renderSourceSessionsDownload({
76
+ entries: rendered.map((entry) => ({
77
+ content: entry.content,
78
+ cwd: entry.cwd,
79
+ fallbackBaseName: 'cursor-thread',
80
+ fileBaseName: entry.fileBaseName,
81
+ sessionId: entry.composerId,
82
+ updatedAtMs: entry.updatedAtMs,
83
+ })),
84
+ fallbackBaseName: 'cursor',
85
+ outputFormat,
86
+ zipArchive: true,
87
+ });
114
88
  };
115
89
 
116
90
  export const findCursorThreadByComposerId = async (composerId: string) => {
@@ -175,6 +149,7 @@ const renderCursorDownload = async (input: {
175
149
  composerId,
176
150
  content,
177
151
  cwd: workspace?.folders[0] ?? null,
152
+ fileBaseName: transcript.head.name || composerId,
178
153
  updatedAtMs: transcript.head.lastUpdatedAtMs,
179
154
  };
180
155
  }),
@@ -185,19 +160,16 @@ const renderCursorDownload = async (input: {
185
160
  }
186
161
 
187
162
  if (rendered.length === 1) {
188
- return {
189
- content: rendered[0]!.content,
190
- fileName: `${buildConversationExportBaseName(
191
- {
192
- cwd: rendered[0]!.cwd,
193
- id: rendered[0]!.composerId,
194
- updatedAtMs: rendered[0]!.updatedAtMs,
195
- },
196
- 'cursor-thread',
197
- )}.${input.outputFormat}`,
198
- mimeType: getExportMimeType(input.outputFormat),
199
- mode: 'download' as const,
200
- };
163
+ const entry = rendered[0]!;
164
+ return renderSourceSessionDownload({
165
+ content: entry.content,
166
+ cwd: entry.cwd,
167
+ fallbackBaseName: 'cursor-thread',
168
+ outputFormat: input.outputFormat,
169
+ sessionId: entry.composerId,
170
+ updatedAtMs: entry.updatedAtMs,
171
+ zipArchive: false,
172
+ });
201
173
  }
202
174
 
203
175
  throw new Error('No Cursor threads selected for export');