spiracha 2.0.0 → 2.1.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 (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
@@ -1,6 +1,10 @@
1
1
  import { createServerFn } from '@tanstack/react-start';
2
2
  import { z } from 'zod';
3
- import { renderSourceSessionDownload } from './source-session-export-server';
3
+ import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
4
+
5
+ const LARGE_CLAUDE_CODE_SESSION_SIZE_BYTES = 8 * 1024 * 1024;
6
+ const CLAUDE_CODE_DETAIL_PREVIEW_ENTRY_LIMIT = 400;
7
+ const CLAUDE_CODE_DETAIL_PREVIEW_LEADING_ENTRY_LIMIT = 100;
4
8
 
5
9
  const workspaceSchema = z.object({
6
10
  workspaceKey: z.string().min(1),
@@ -19,6 +23,19 @@ const exportSessionSchema = z.object({
19
23
  zipArchive: z.boolean().default(false),
20
24
  });
21
25
 
26
+ const exportSessionsSchema = z.object({
27
+ includeCommentary: z.boolean().default(true),
28
+ includeMetadata: z.boolean().default(true),
29
+ includeTools: z.boolean().default(true),
30
+ outputFormat: z.enum(['md', 'txt']).default('md'),
31
+ sessionIds: z.array(z.string().min(1)).min(1),
32
+ zipArchive: z.boolean().default(true),
33
+ });
34
+
35
+ const deleteSessionsSchema = z.object({
36
+ sessionIds: z.array(z.string().min(1)).min(1),
37
+ });
38
+
22
39
  export const listClaudeCodeWorkspacesFn = createServerFn({ method: 'GET' }).handler(async () => {
23
40
  const { listClaudeCodeWorkspaceGroups } = await import('@spiracha/lib/claude-code-db');
24
41
  return listClaudeCodeWorkspaceGroups();
@@ -32,21 +49,112 @@ export const listClaudeCodeSessionsFn = createServerFn({ method: 'GET' })
32
49
  });
33
50
 
34
51
  const loadClaudeCodeSessionTranscript = async (sessionId: string) => {
52
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
35
53
  const { readClaudeCodeSessionTranscript, resolveClaudeCodeProjectsDir } = await import(
36
54
  '@spiracha/lib/claude-code-db'
37
55
  );
38
- const transcript = await readClaudeCodeSessionTranscript(resolveClaudeCodeProjectsDir(), sessionId);
39
- if (!transcript) {
40
- throw new Error(`Claude Code session not found: ${sessionId}`);
56
+ const projectsDir = resolveClaudeCodeProjectsDir();
57
+ return runWithTranscriptLoadLimit(
58
+ async () => {
59
+ const transcript = await readClaudeCodeSessionTranscript(projectsDir, sessionId);
60
+ if (!transcript) {
61
+ throw new Error(`Claude Code session not found: ${sessionId}`);
62
+ }
63
+
64
+ return transcript;
65
+ },
66
+ {
67
+ id: sessionId,
68
+ path: projectsDir,
69
+ source: 'claude-code-ui',
70
+ },
71
+ );
72
+ };
73
+
74
+ export const buildClaudeCodeSessionDetailPreview = (
75
+ transcript: Awaited<ReturnType<typeof loadClaudeCodeSessionTranscript>>,
76
+ ) => {
77
+ if (transcript.entries.length <= CLAUDE_CODE_DETAIL_PREVIEW_ENTRY_LIMIT) {
78
+ return transcript;
41
79
  }
42
80
 
43
- return transcript;
81
+ const trailingEntryLimit = CLAUDE_CODE_DETAIL_PREVIEW_ENTRY_LIMIT - CLAUDE_CODE_DETAIL_PREVIEW_LEADING_ENTRY_LIMIT;
82
+ return {
83
+ ...transcript,
84
+ entries: [
85
+ ...transcript.entries.slice(0, CLAUDE_CODE_DETAIL_PREVIEW_LEADING_ENTRY_LIMIT),
86
+ ...transcript.entries.slice(-trailingEntryLimit),
87
+ ].map((entry) => ({
88
+ ...entry,
89
+ parts: entry.parts.map((part) => ({ ...part, raw: {} })),
90
+ raw: {},
91
+ })),
92
+ isPartial: true,
93
+ omittedEntryCount: transcript.entries.length - CLAUDE_CODE_DETAIL_PREVIEW_ENTRY_LIMIT,
94
+ rawEvents: [],
95
+ rawPayloadsOmitted: true,
96
+ };
97
+ };
98
+
99
+ export const loadClaudeCodeSessionDetail = async (sessionId: string) => {
100
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
101
+ const { readClaudeCodeSessionTranscript, resolveClaudeCodeProjectsDir } = await import(
102
+ '@spiracha/lib/claude-code-db'
103
+ );
104
+ const projectsDir = resolveClaudeCodeProjectsDir();
105
+ return runWithTranscriptLoadLimit(
106
+ async () => {
107
+ const transcript = await readClaudeCodeSessionTranscript(projectsDir, sessionId, {
108
+ maxRawPayloadFileSizeBytes: LARGE_CLAUDE_CODE_SESSION_SIZE_BYTES,
109
+ });
110
+ if (!transcript) {
111
+ throw new Error(`Claude Code session not found: ${sessionId}`);
112
+ }
113
+
114
+ return buildClaudeCodeSessionDetailPreview(transcript);
115
+ },
116
+ {
117
+ id: sessionId,
118
+ path: projectsDir,
119
+ source: 'claude-code-ui-detail',
120
+ },
121
+ );
122
+ };
123
+
124
+ export const loadClaudeCodeSessionFullDetail = async (sessionId: string) => {
125
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
126
+ const { readClaudeCodeSessionTranscript, resolveClaudeCodeProjectsDir } = await import(
127
+ '@spiracha/lib/claude-code-db'
128
+ );
129
+ const projectsDir = resolveClaudeCodeProjectsDir();
130
+ return runWithTranscriptLoadLimit(
131
+ async () => {
132
+ const transcript = await readClaudeCodeSessionTranscript(projectsDir, sessionId, {
133
+ includeRawPayloads: false,
134
+ });
135
+ if (!transcript) {
136
+ throw new Error(`Claude Code session not found: ${sessionId}`);
137
+ }
138
+ return transcript;
139
+ },
140
+ {
141
+ id: sessionId,
142
+ path: projectsDir,
143
+ source: 'claude-code-ui-full-detail',
144
+ },
145
+ );
44
146
  };
45
147
 
46
148
  export const getClaudeCodeSessionDetailFn = createServerFn({ method: 'GET' })
47
149
  .validator(sessionSchema)
48
150
  .handler(async ({ data }) => {
49
- return loadClaudeCodeSessionTranscript(data.sessionId);
151
+ return loadClaudeCodeSessionDetail(data.sessionId);
152
+ });
153
+
154
+ export const getClaudeCodeSessionTranscriptFn = createServerFn({ method: 'GET' })
155
+ .validator(sessionSchema)
156
+ .handler(async ({ data }) => {
157
+ return loadClaudeCodeSessionFullDetail(data.sessionId);
50
158
  });
51
159
 
52
160
  export const exportClaudeCodeSessionFn = createServerFn({ method: 'POST' })
@@ -67,9 +175,69 @@ export const exportClaudeCodeSessionFn = createServerFn({ method: 'POST' })
67
175
 
68
176
  return renderSourceSessionDownload({
69
177
  content,
178
+ cwd: transcript.session.cwd ?? transcript.session.worktree,
70
179
  fallbackBaseName: 'claude-code-session',
71
- fileBaseName: transcript.session.title || transcript.session.sessionId,
72
180
  outputFormat: data.outputFormat,
181
+ sessionId: transcript.session.sessionId,
182
+ updatedAtMs: transcript.session.lastActiveAtMs,
73
183
  zipArchive: data.zipArchive,
74
184
  });
75
185
  });
186
+
187
+ export const exportClaudeCodeSessionsFn = createServerFn({ method: 'POST' })
188
+ .validator(exportSessionsSchema)
189
+ .handler(async ({ data }) => {
190
+ const { renderClaudeCodeTranscript } = await import('@spiracha/lib/claude-code-transcript');
191
+ const entries = await Promise.all(
192
+ data.sessionIds.map(async (sessionId) => {
193
+ const transcript = await loadClaudeCodeSessionTranscript(sessionId);
194
+ const content = renderClaudeCodeTranscript(transcript, {
195
+ includeCommentary: data.includeCommentary,
196
+ includeMetadata: data.includeMetadata,
197
+ includeTools: data.includeTools,
198
+ outputFormat: data.outputFormat,
199
+ });
200
+
201
+ if (!content) {
202
+ throw new Error(`Claude Code session has no exportable content: ${sessionId}`);
203
+ }
204
+
205
+ return {
206
+ content,
207
+ cwd: transcript.session.cwd ?? transcript.session.worktree,
208
+ fallbackBaseName: 'claude-code-session',
209
+ fileBaseName: transcript.session.title || transcript.session.sessionId,
210
+ sessionId: transcript.session.sessionId,
211
+ updatedAtMs: transcript.session.lastActiveAtMs,
212
+ };
213
+ }),
214
+ );
215
+
216
+ return renderSourceSessionsDownload({
217
+ entries,
218
+ fallbackBaseName: 'claude-code-sessions',
219
+ outputFormat: data.outputFormat,
220
+ zipArchive: data.zipArchive,
221
+ });
222
+ });
223
+
224
+ export const deleteClaudeCodeSessionFn = createServerFn({ method: 'POST' })
225
+ .validator(sessionSchema)
226
+ .handler(async ({ data }) => {
227
+ const { deleteClaudeCodeSession, resolveClaudeCodeProjectsDir } = await import('@spiracha/lib/claude-code-db');
228
+ return deleteClaudeCodeSession(resolveClaudeCodeProjectsDir(), data.sessionId);
229
+ });
230
+
231
+ export const deleteClaudeCodeSessionsFn = createServerFn({ method: 'POST' })
232
+ .validator(deleteSessionsSchema)
233
+ .handler(async ({ data }) => {
234
+ const { deleteClaudeCodeSession, resolveClaudeCodeProjectsDir } = await import('@spiracha/lib/claude-code-db');
235
+ const projectsDir = resolveClaudeCodeProjectsDir();
236
+ const results = await Promise.all(
237
+ data.sessionIds.map((sessionId) => deleteClaudeCodeSession(projectsDir, sessionId)),
238
+ );
239
+ return {
240
+ deletedFiles: [...new Set(results.flatMap((result) => result.deletedFiles))],
241
+ deletedSessionIds: [...new Set(results.flatMap((result) => result.deletedSessionIds))],
242
+ };
243
+ });
@@ -3,7 +3,10 @@ import type {
3
3
  ClaudeCodeTranscriptEntry,
4
4
  ClaudeCodeTranscriptPart,
5
5
  } from '@spiracha/lib/claude-code-exporter-types';
6
- import { getClaudeCodeAssistantMessagePhase } from '@spiracha/lib/claude-code-transcript-phase';
6
+ import {
7
+ getClaudeCodeAssistantMessagePhase,
8
+ isClaudeCodeSyntheticTranscriptEntry,
9
+ } from '@spiracha/lib/claude-code-transcript-phase';
7
10
  import type { ThreadEvent, ThreadTranscriptStats } from '@spiracha/lib/codex-browser-types';
8
11
  import type { JsonValue } from '@spiracha/lib/shared';
9
12
  import { getThreadTranscriptStats } from './thread-transcript-stats';
@@ -141,6 +144,10 @@ export const claudeCodeTranscriptToThreadEvents = (transcript: ClaudeCodeSession
141
144
  const events: ThreadEvent[] = [];
142
145
  let sequence = 0;
143
146
  for (const entry of transcript.entries) {
147
+ if (isClaudeCodeSyntheticTranscriptEntry(entry)) {
148
+ continue;
149
+ }
150
+
144
151
  for (const part of entry.parts) {
145
152
  events.push(...partToEvents(entry, part, sequence));
146
153
  sequence += 1;
@@ -5,10 +5,18 @@ import {
5
5
  getDashboardSummaryFn,
6
6
  getThreadSnapshotFn,
7
7
  getThreadTranscriptFn,
8
+ getThreadTranscriptPreviewFn,
8
9
  listProjectsFn,
9
10
  listProjectThreadsFn,
10
11
  } from './codex-server';
11
12
 
13
+ type ThreadTranscriptFilters = {
14
+ showCommentary: boolean;
15
+ showExtraEvents: boolean;
16
+ showToolCalls: boolean;
17
+ showUserMessages: boolean;
18
+ };
19
+
12
20
  const retrySqliteQuery = (failureCount: number, error: unknown) => {
13
21
  return failureCount < 3 && isRetryableSqliteError(error);
14
22
  };
@@ -41,10 +49,18 @@ export const projectThreadsQueryOptions = (project: string) =>
41
49
  retryDelay,
42
50
  });
43
51
 
44
- export const threadSnapshotQueryOptions = (threadId: string) =>
52
+ export const threadSnapshotQueryOptions = (threadId: string, filters?: ThreadTranscriptFilters) =>
53
+ queryOptions({
54
+ queryFn: () => getThreadSnapshotFn({ data: { filters, threadId } }),
55
+ queryKey: ['thread', threadId, filters ?? 'all'],
56
+ retry: retrySqliteQuery,
57
+ retryDelay,
58
+ });
59
+
60
+ export const threadTranscriptPreviewQueryOptions = (threadId: string, filters?: ThreadTranscriptFilters) =>
45
61
  queryOptions({
46
- queryFn: () => getThreadSnapshotFn({ data: { threadId } }),
47
- queryKey: ['thread', threadId],
62
+ queryFn: () => getThreadTranscriptPreviewFn({ data: { filters, threadId } }),
63
+ queryKey: ['thread-transcript-preview', threadId, filters ?? 'all'],
48
64
  retry: retrySqliteQuery,
49
65
  retryDelay,
50
66
  });
@@ -1,21 +1,4 @@
1
- import { getCodexAnalytics } from '@spiracha/lib/codex-analytics';
2
- import {
3
- deleteCodexProject,
4
- deleteCodexThread,
5
- deleteCodexThreads,
6
- getCodexDashboardSummary,
7
- getThreadBrowseData,
8
- listCodexProjects,
9
- listProjectThreads,
10
- resolveCodexThreadDbPath,
11
- } from '@spiracha/lib/codex-browser-db';
12
- import { renderCodexThreadDownload, renderCodexThreadsDownload } from '@spiracha/lib/codex-browser-export';
13
- import {
14
- getCachedParsedCodexTranscript,
15
- getCachedThreadTranscriptPreview,
16
- getThreadRolloutLoadState,
17
- } from '@spiracha/lib/codex-thread-cache';
18
- import { recoverCodexProjectThreads } from '@spiracha/lib/codex-thread-recovery';
1
+ import type { ParsedCodexTranscript } from '@spiracha/lib/codex-browser-types';
19
2
  import { createServerFn } from '@tanstack/react-start';
20
3
  import { z } from 'zod';
21
4
 
@@ -32,6 +15,18 @@ const threadSchema = z.object({
32
15
  threadId: z.string().min(1),
33
16
  });
34
17
 
18
+ const transcriptFiltersSchema = z.object({
19
+ showCommentary: z.boolean(),
20
+ showExtraEvents: z.boolean(),
21
+ showToolCalls: z.boolean(),
22
+ showUserMessages: z.boolean(),
23
+ });
24
+
25
+ const threadSnapshotSchema = z.object({
26
+ filters: transcriptFiltersSchema.optional(),
27
+ threadId: z.string().min(1),
28
+ });
29
+
35
30
  const deleteThreadSchema = z.object({
36
31
  deleteSessionFiles: z.boolean().default(false),
37
32
  threadId: z.string().min(1),
@@ -68,76 +63,157 @@ const exportThreadsSchema = z.object({
68
63
  zipArchive: z.boolean().default(true),
69
64
  });
70
65
 
71
- const getDbPath = () => process.env.SPIRACHA_CODEX_DB?.trim() || resolveCodexThreadDbPath();
66
+ const getDbPath = async () => {
67
+ const configuredDbPath = process.env.SPIRACHA_CODEX_DB?.trim();
68
+ if (configuredDbPath) {
69
+ return configuredDbPath;
70
+ }
71
+
72
+ const { resolveCodexThreadDbPath } = await import('@spiracha/lib/codex-browser-db');
73
+ return resolveCodexThreadDbPath();
74
+ };
72
75
 
73
76
  const isMissingFileError = (error: unknown) => {
74
77
  return error instanceof Error && /ENOENT|no such file/i.test(error.message);
75
78
  };
76
79
 
80
+ const logCodexThreadLoad = (event: string, details: Record<string, unknown>) => {
81
+ console.info(`[spiracha:codex-thread] ${event}`, details);
82
+ };
83
+
77
84
  export const getDashboardSummaryFn = createServerFn({ method: 'GET' }).handler(async () => {
78
- return getCodexDashboardSummary(getDbPath());
85
+ const { getCodexDashboardSummary } = await import('@spiracha/lib/codex-browser-db');
86
+ return getCodexDashboardSummary(await getDbPath());
79
87
  });
80
88
 
81
89
  export const listProjectsFn = createServerFn({ method: 'GET' }).handler(async () => {
82
- return listCodexProjects(getDbPath());
90
+ const { listCodexProjects } = await import('@spiracha/lib/codex-browser-db');
91
+ return listCodexProjects(await getDbPath());
83
92
  });
84
93
 
85
94
  export const listProjectThreadsFn = createServerFn({ method: 'GET' })
86
95
  .validator(projectSchema)
87
96
  .handler(async ({ data }) => {
88
- return listProjectThreads(getDbPath(), data.project, {
97
+ const { listProjectThreads } = await import('@spiracha/lib/codex-browser-db');
98
+ return listProjectThreads(await getDbPath(), data.project, {
89
99
  includeTranscriptStats: false,
90
100
  });
91
101
  });
92
102
 
93
103
  export const getThreadSnapshotFn = createServerFn({ method: 'GET' })
94
- .validator(threadSchema)
104
+ .validator(threadSnapshotSchema)
95
105
  .handler(async ({ data }) => {
96
- const dbPath = getDbPath();
106
+ const startedAt = Date.now();
107
+ const [{ getThreadBrowseData }, { getThreadRolloutLoadState }] = await Promise.all([
108
+ import('@spiracha/lib/codex-browser-db'),
109
+ import('@spiracha/lib/codex-thread-cache'),
110
+ ]);
111
+ const dbPath = await getDbPath();
112
+ logCodexThreadLoad('snapshot_start', {
113
+ threadId: data.threadId,
114
+ });
97
115
  const browseData = getThreadBrowseData(dbPath, data.threadId);
98
- const rollout = await getThreadRolloutLoadState(browseData.thread.rollout_path);
99
- let transcript = null;
100
- let transcriptState: 'available' | 'deferred' | 'missing' = rollout.shouldDeferTranscriptLoad
101
- ? 'deferred'
102
- : 'available';
103
-
104
- if (!rollout.shouldDeferTranscriptLoad) {
105
- try {
106
- transcript = await getCachedParsedCodexTranscript(browseData.thread.rollout_path);
107
- } catch (error) {
108
- if (!isMissingFileError(error)) {
109
- throw error;
110
- }
111
-
112
- transcriptState = 'missing';
116
+ const transcript: ParsedCodexTranscript | null = null;
117
+ let rollout: Awaited<ReturnType<typeof getThreadRolloutLoadState>>;
118
+
119
+ try {
120
+ rollout = await getThreadRolloutLoadState(browseData.thread.rollout_path);
121
+ } catch (error) {
122
+ if (!isMissingFileError(error)) {
123
+ throw error;
113
124
  }
125
+
126
+ rollout = {
127
+ fileSizeBytes: null,
128
+ shouldDeferTranscriptLoad: false,
129
+ };
114
130
  }
115
131
 
132
+ const transcriptState: 'available' | 'deferred' | 'missing' =
133
+ rollout.fileSizeBytes === null ? 'missing' : rollout.shouldDeferTranscriptLoad ? 'deferred' : 'available';
134
+ logCodexThreadLoad('snapshot_ready', {
135
+ durationMs: Date.now() - startedAt,
136
+ fileSizeBytes: rollout.fileSizeBytes,
137
+ shouldDeferTranscriptLoad: rollout.shouldDeferTranscriptLoad,
138
+ threadId: data.threadId,
139
+ transcriptState,
140
+ });
141
+
116
142
  return {
117
143
  ...browseData,
118
- availableTools:
119
- browseData.dynamicTools.length > 0
120
- ? browseData.dynamicTools
121
- : (transcript?.sessionMeta.dynamicTools ?? []),
144
+ availableTools: browseData.dynamicTools,
122
145
  rollout,
123
146
  transcript,
124
147
  transcriptState,
125
148
  };
126
149
  });
127
150
 
151
+ export const loadThreadTranscriptPreview = async (
152
+ threadId: string,
153
+ filters?: z.infer<typeof transcriptFiltersSchema>,
154
+ ) => {
155
+ const startedAt = Date.now();
156
+ const [{ getThreadBrowseData }, { getCachedThreadTranscriptPreview }] = await Promise.all([
157
+ import('@spiracha/lib/codex-browser-db'),
158
+ import('@spiracha/lib/codex-thread-cache'),
159
+ ]);
160
+ const dbPath = await getDbPath();
161
+ const browseData = getThreadBrowseData(dbPath, threadId);
162
+ logCodexThreadLoad('preview_start', {
163
+ rolloutPath: browseData.thread.rollout_path,
164
+ threadId,
165
+ });
166
+ const transcript = await getCachedThreadTranscriptPreview(browseData.thread.rollout_path, {
167
+ filters,
168
+ });
169
+ logCodexThreadLoad('preview_ready', {
170
+ durationMs: Date.now() - startedAt,
171
+ eventCount: transcript.events.length,
172
+ isPartial: transcript.isPartial,
173
+ threadId,
174
+ });
175
+ return transcript;
176
+ };
177
+
178
+ export const loadThreadTranscript = async (threadId: string) => {
179
+ const [{ getThreadBrowseData }, { getCachedParsedCodexTranscript }] = await Promise.all([
180
+ import('@spiracha/lib/codex-browser-db'),
181
+ import('@spiracha/lib/codex-thread-cache'),
182
+ ]);
183
+ const dbPath = await getDbPath();
184
+ const browseData = getThreadBrowseData(dbPath, threadId);
185
+ const startedAt = Date.now();
186
+ logCodexThreadLoad('full_start', {
187
+ rolloutPath: browseData.thread.rollout_path,
188
+ threadId,
189
+ });
190
+ const transcript = await getCachedParsedCodexTranscript(browseData.thread.rollout_path);
191
+ logCodexThreadLoad('full_ready', {
192
+ durationMs: Date.now() - startedAt,
193
+ eventCount: transcript.events.length,
194
+ threadId,
195
+ });
196
+ return transcript;
197
+ };
198
+
199
+ export const getThreadTranscriptPreviewFn = createServerFn({ method: 'GET' })
200
+ .validator(threadSnapshotSchema)
201
+ .handler(async ({ data }) => {
202
+ return loadThreadTranscriptPreview(data.threadId, data.filters);
203
+ });
204
+
128
205
  export const getThreadTranscriptFn = createServerFn({ method: 'GET' })
129
206
  .validator(threadSchema)
130
207
  .handler(async ({ data }) => {
131
- const dbPath = getDbPath();
132
- const browseData = getThreadBrowseData(dbPath, data.threadId);
133
- return getCachedThreadTranscriptPreview(browseData.thread.rollout_path);
208
+ return loadThreadTranscript(data.threadId);
134
209
  });
135
210
 
136
211
  export const getAnalyticsFn = createServerFn({ method: 'GET' })
137
212
  .validator(analyticsSchema)
138
213
  .handler(async ({ data }) => {
214
+ const { getCodexAnalytics } = await import('@spiracha/lib/codex-analytics');
139
215
  return getCodexAnalytics({
140
- dbPath: getDbPath(),
216
+ dbPath: await getDbPath(),
141
217
  project: data.project,
142
218
  });
143
219
  });
@@ -145,8 +221,9 @@ export const getAnalyticsFn = createServerFn({ method: 'GET' })
145
221
  export const exportThreadFn = createServerFn({ method: 'POST' })
146
222
  .validator(exportSchema)
147
223
  .handler(async ({ data }) => {
224
+ const { renderCodexThreadDownload } = await import('@spiracha/lib/codex-browser-export');
148
225
  return renderCodexThreadDownload({
149
- dbPath: getDbPath(),
226
+ dbPath: await getDbPath(),
150
227
  includeCommentary: data.includeCommentary,
151
228
  includeMetadata: data.includeMetadata,
152
229
  includeTools: data.includeTools,
@@ -163,8 +240,9 @@ export const exportThreadFn = createServerFn({ method: 'POST' })
163
240
  export const exportThreadsFn = createServerFn({ method: 'POST' })
164
241
  .validator(exportThreadsSchema)
165
242
  .handler(async ({ data }) => {
243
+ const { renderCodexThreadsDownload } = await import('@spiracha/lib/codex-browser-export');
166
244
  return renderCodexThreadsDownload({
167
- dbPath: getDbPath(),
245
+ dbPath: await getDbPath(),
168
246
  includeCommentary: data.includeCommentary,
169
247
  includeMetadata: data.includeMetadata,
170
248
  includeTools: data.includeTools,
@@ -181,7 +259,8 @@ export const exportThreadsFn = createServerFn({ method: 'POST' })
181
259
  export const deleteThreadFn = createServerFn({ method: 'POST' })
182
260
  .validator(deleteThreadSchema)
183
261
  .handler(async ({ data }) => {
184
- return deleteCodexThread(getDbPath(), data.threadId, {
262
+ const { deleteCodexThread } = await import('@spiracha/lib/codex-browser-db');
263
+ return deleteCodexThread(await getDbPath(), data.threadId, {
185
264
  deleteSessionFiles: data.deleteSessionFiles,
186
265
  });
187
266
  });
@@ -189,7 +268,8 @@ export const deleteThreadFn = createServerFn({ method: 'POST' })
189
268
  export const deleteThreadsFn = createServerFn({ method: 'POST' })
190
269
  .validator(deleteThreadsSchema)
191
270
  .handler(async ({ data }) => {
192
- return deleteCodexThreads(getDbPath(), data.threadIds, {
271
+ const { deleteCodexThreads } = await import('@spiracha/lib/codex-browser-db');
272
+ return deleteCodexThreads(await getDbPath(), data.threadIds, {
193
273
  deleteSessionFiles: data.deleteSessionFiles,
194
274
  });
195
275
  });
@@ -197,7 +277,8 @@ export const deleteThreadsFn = createServerFn({ method: 'POST' })
197
277
  export const deleteProjectFn = createServerFn({ method: 'POST' })
198
278
  .validator(deleteProjectSchema)
199
279
  .handler(async ({ data }) => {
200
- return deleteCodexProject(getDbPath(), data.project, {
280
+ const { deleteCodexProject } = await import('@spiracha/lib/codex-browser-db');
281
+ return deleteCodexProject(await getDbPath(), data.project, {
201
282
  deleteSessionFiles: data.deleteSessionFiles,
202
283
  });
203
284
  });
@@ -205,5 +286,6 @@ export const deleteProjectFn = createServerFn({ method: 'POST' })
205
286
  export const recoverProjectThreadsFn = createServerFn({ method: 'POST' })
206
287
  .validator(projectSchema)
207
288
  .handler(async ({ data }) => {
208
- return recoverCodexProjectThreads(getDbPath(), data.project);
289
+ const { recoverCodexProjectThreads } = await import('@spiracha/lib/codex-thread-recovery');
290
+ return recoverCodexProjectThreads(await getDbPath(), data.project);
209
291
  });
@@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import {
6
+ buildBatchExportBaseName,
7
+ buildConversationExportBaseName,
6
8
  getExportMimeType,
7
9
  resolveUniqueExportFileBaseName,
8
10
  sanitizeExportFileName,
@@ -72,12 +74,21 @@ const toSafeExportName = (value: string) => {
72
74
  };
73
75
 
74
76
  const renderCursorZipDownload = async (
75
- rendered: Array<{ composerId: string; content: string }>,
77
+ rendered: Array<{ composerId: string; content: string; cwd: string | null; updatedAtMs: number | null }>,
76
78
  outputFormat: 'md' | 'txt',
77
79
  ) => {
78
80
  const exportDir = await ensureUiExportDir();
79
81
  const exportBaseName =
80
- rendered.length === 1 ? `${toSafeExportName(rendered[0]!.composerId)}` : `cursor-threads-${rendered.length}`;
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');
81
92
  const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${exportBaseName}-`));
82
93
  const zipPath = path.join(exportDir, `${exportBaseName}-${randomUUID()}.zip`);
83
94
  const usedBaseNames = new Map<string, number>();
@@ -123,13 +134,23 @@ const renderCursorDownload = async (input: {
123
134
  outputFormat: 'md' | 'txt';
124
135
  zipArchive: boolean;
125
136
  }) => {
137
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
126
138
  const { readCursorThreadTranscriptWithAgentFiles } = await import('@spiracha/lib/cursor-db');
127
139
  const { getCursorGlobalDbPath } = await import('@spiracha/lib/cursor-exporter-types');
128
140
  const { renderCursorTranscript } = await import('@spiracha/lib/cursor-transcript');
129
141
  const globalDbPath = getCursorGlobalDbPath();
142
+ const { listCursorWorkspaceGroups } = await import('@spiracha/lib/cursor-db');
143
+ const workspaceGroups = await listCursorWorkspaceGroups();
130
144
  const rendered = await Promise.all(
131
145
  input.composerIds.map(async (composerId) => {
132
- const transcript = await readCursorThreadTranscriptWithAgentFiles(globalDbPath, composerId);
146
+ const transcript = await runWithTranscriptLoadLimit(
147
+ () => readCursorThreadTranscriptWithAgentFiles(globalDbPath, composerId),
148
+ {
149
+ id: composerId,
150
+ path: globalDbPath,
151
+ source: 'cursor-ui-export',
152
+ },
153
+ );
133
154
  if (!transcript) {
134
155
  throw new Error(`No transcript found for thread: ${composerId}`);
135
156
  }
@@ -145,9 +166,16 @@ const renderCursorDownload = async (input: {
145
166
  throw new Error(`Thread has no exportable content: ${composerId}`);
146
167
  }
147
168
 
169
+ const workspace =
170
+ workspaceGroups.find((candidate) =>
171
+ candidate.buckets.some((bucket) => bucket.threadComposerIds.includes(composerId)),
172
+ ) ?? null;
173
+
148
174
  return {
149
175
  composerId,
150
176
  content,
177
+ cwd: workspace?.folders[0] ?? null,
178
+ updatedAtMs: transcript.head.lastUpdatedAtMs,
151
179
  };
152
180
  }),
153
181
  );
@@ -159,7 +187,14 @@ const renderCursorDownload = async (input: {
159
187
  if (rendered.length === 1) {
160
188
  return {
161
189
  content: rendered[0]!.content,
162
- fileName: `${toSafeExportName(rendered[0]!.composerId)}.${input.outputFormat}`,
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}`,
163
198
  mimeType: getExportMimeType(input.outputFormat),
164
199
  mode: 'download' as const,
165
200
  };
@@ -184,6 +219,7 @@ export const listCursorThreadsFn = createServerFn({ method: 'GET' })
184
219
  export const getCursorThreadDetailFn = createServerFn({ method: 'GET' })
185
220
  .validator(threadSchema)
186
221
  .handler(async ({ data }) => {
222
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
187
223
  const { readCursorThreadTranscriptWithAgentFiles } = await import('@spiracha/lib/cursor-db');
188
224
  const { getCursorGlobalDbPath } = await import('@spiracha/lib/cursor-exporter-types');
189
225
  const thread = await findCursorThreadByComposerId(data.composerId);
@@ -191,7 +227,14 @@ export const getCursorThreadDetailFn = createServerFn({ method: 'GET' })
191
227
  throw new Error(`Cursor thread not found: ${data.composerId}`);
192
228
  }
193
229
 
194
- const transcript = await readCursorThreadTranscriptWithAgentFiles(getCursorGlobalDbPath(), data.composerId);
230
+ const transcript = await runWithTranscriptLoadLimit(
231
+ () => readCursorThreadTranscriptWithAgentFiles(getCursorGlobalDbPath(), data.composerId),
232
+ {
233
+ id: data.composerId,
234
+ path: getCursorGlobalDbPath(),
235
+ source: 'cursor-ui-detail',
236
+ },
237
+ );
195
238
  return {
196
239
  thread,
197
240
  transcript,