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
@@ -1,4 +1,10 @@
1
- import { listKiroSessionsForGroup, listKiroWorkspaceGroups, readKiroSessionTranscript } from '../kiro-db';
1
+ import { mapWithConcurrency } from '../concurrency';
2
+ import {
3
+ deleteKiroSession,
4
+ listKiroSessionsForGroup,
5
+ listKiroWorkspaceGroups,
6
+ readKiroSessionTranscript,
7
+ } from '../kiro-db';
2
8
  import type {
3
9
  KiroSessionSummary,
4
10
  KiroSessionTranscript,
@@ -8,10 +14,13 @@ import type {
8
14
  import { resolveKiroWorkspaceSessionsDir } from '../kiro-exporter-types';
9
15
  import { getFinalKiroAssistantMessageEntryIds, getKiroMessagePhase } from '../kiro-transcript-phase';
10
16
  import { cleanInlineTitle } from '../shared';
17
+ import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
11
18
  import {
19
+ createConversationUiPath,
12
20
  createDeepLinks,
13
21
  createTextMessage,
14
22
  finalizeMessages,
23
+ isWithinUpdatedWindow,
15
24
  normalizeAssistantPhase,
16
25
  normalizeRole,
17
26
  toDateMs,
@@ -23,10 +32,13 @@ import type {
23
32
  ConversationDetail,
24
33
  ConversationMessage,
25
34
  ConversationPathMatch,
35
+ DeleteConversationOptions,
26
36
  GetConversationOptions,
27
37
  ListConversationsForPathOptions,
28
38
  } from './types';
29
39
 
40
+ const KIRO_CONVERSATION_HYDRATION_CONCURRENCY = 4;
41
+
30
42
  const getSessionsDir = (options: { locations?: { kiroWorkspaceSessionsDir?: string } }) =>
31
43
  options.locations?.kiroWorkspaceSessionsDir ?? resolveKiroWorkspaceSessionsDir();
32
44
 
@@ -74,8 +86,16 @@ const buildConversation = async (
74
86
  sessionsDir: string,
75
87
  matches: ConversationPathMatch[],
76
88
  options: Pick<ListConversationsForPathOptions, 'includeMessages' | 'messageSelector'>,
89
+ loadedTranscript: KiroSessionTranscript | null = null,
77
90
  ): Promise<ConversationDetail> => {
78
- const transcript = options.includeMessages ? await readKiroSessionTranscript(sessionsDir, session.sessionId) : null;
91
+ const transcript = options.includeMessages
92
+ ? (loadedTranscript ??
93
+ (await runWithTranscriptLoadLimit(() => readKiroSessionTranscript(sessionsDir, session.sessionId), {
94
+ id: session.sessionId,
95
+ path: session.filePath,
96
+ source: 'kiro-api',
97
+ })))
98
+ : null;
79
99
  const allMessages = transcript ? transcriptToMessages(transcript) : [];
80
100
  const messages = options.includeMessages
81
101
  ? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
@@ -83,7 +103,11 @@ const buildConversation = async (
83
103
 
84
104
  return {
85
105
  createdAtMs: session.createdAtMs,
86
- deepLinks: createDeepLinks('kiro', session.sessionId, `/kiro-sessions/${session.sessionId}`),
106
+ deepLinks: createDeepLinks(
107
+ 'kiro',
108
+ session.sessionId,
109
+ createConversationUiPath('kiro-sessions', session.sessionId),
110
+ ),
87
111
  id: session.sessionId,
88
112
  matches,
89
113
  messageCount: options.includeMessages ? allMessages.length : session.messageCount,
@@ -113,10 +137,14 @@ const listKiroConversationsForPath = async (options: ListConversationsForPathOpt
113
137
  if (!match) {
114
138
  continue;
115
139
  }
116
- const sessions = await listKiroSessionsForGroup(group.key, sessionsDir);
117
- for (const session of sessions) {
118
- conversations.push(await buildConversation(session, sessionsDir, [match], options));
119
- }
140
+ const sessions = (await listKiroSessionsForGroup(group.key, sessionsDir)).filter((session) =>
141
+ isWithinUpdatedWindow(session.lastActiveAtMs, options),
142
+ );
143
+ conversations.push(
144
+ ...(await mapWithConcurrency(sessions, KIRO_CONVERSATION_HYDRATION_CONCURRENCY, (session) =>
145
+ buildConversation(session, sessionsDir, [match], options),
146
+ )),
147
+ );
120
148
  }
121
149
 
122
150
  return conversations;
@@ -124,16 +152,35 @@ const listKiroConversationsForPath = async (options: ListConversationsForPathOpt
124
152
 
125
153
  const getKiroConversation = async (options: GetConversationOptions): Promise<ConversationDetail | null> => {
126
154
  const sessionsDir = getSessionsDir(options);
127
- const transcript = await readKiroSessionTranscript(sessionsDir, options.id);
155
+ const transcript = await runWithTranscriptLoadLimit(() => readKiroSessionTranscript(sessionsDir, options.id), {
156
+ id: options.id,
157
+ path: sessionsDir,
158
+ source: 'kiro-api',
159
+ });
128
160
  return transcript
129
- ? buildConversation(transcript.session, sessionsDir, [], {
130
- includeMessages: true,
131
- messageSelector: options.messageSelector ?? 'all',
132
- })
161
+ ? buildConversation(
162
+ transcript.session,
163
+ sessionsDir,
164
+ [],
165
+ {
166
+ includeMessages: true,
167
+ messageSelector: options.messageSelector ?? 'all',
168
+ },
169
+ transcript,
170
+ )
133
171
  : null;
134
172
  };
135
173
 
174
+ const deleteKiroConversation = async (options: DeleteConversationOptions) => {
175
+ const result = await deleteKiroSession(getSessionsDir(options), options.id);
176
+ return {
177
+ deletedFiles: result.deletedFiles,
178
+ deletedIds: result.deletedSessionIds,
179
+ };
180
+ };
181
+
136
182
  export const kiroConversationAdapter: ConversationAdapter = {
183
+ deleteConversation: deleteKiroConversation,
137
184
  getConversation: getKiroConversation,
138
185
  listConversationsForPath: listKiroConversationsForPath,
139
186
  source: 'kiro',
@@ -14,12 +14,9 @@ const selectLastAssistantMessage = (messages: ConversationMessage[]) => {
14
14
  return latestByOrder(messages.filter((message) => message.role === 'assistant'));
15
15
  };
16
16
 
17
- // Not every integration can identify final answers. In that case, API list/export defaults still
18
- // return the latest assistant message instead of silently omitting the conversation.
19
17
  const selectLastFinalAnswer = (messages: ConversationMessage[]) => {
20
- return (
21
- latestByOrder(messages.filter((message) => message.role === 'assistant' && message.phase === 'final_answer')) ??
22
- selectLastAssistantMessage(messages)
18
+ return latestByOrder(
19
+ messages.filter((message) => message.role === 'assistant' && message.phase === 'final_answer'),
23
20
  );
24
21
  };
25
22
 
@@ -1,4 +1,6 @@
1
+ import { mapWithConcurrency } from '../concurrency';
1
2
  import {
3
+ deleteOpenCodeSession,
2
4
  listOpenCodeSessionsForGroup,
3
5
  listOpenCodeWorkspaceGroups,
4
6
  readOpenCodeSessionTranscript,
@@ -12,10 +14,13 @@ import { resolveOpenCodeDbPath } from '../opencode-exporter-types';
12
14
  import { splitOpenCodeThinkTaggedText } from '../opencode-think-tags';
13
15
  import { getFinalOpenCodeAssistantTextPartIds, getOpenCodeTextPartPhase } from '../opencode-transcript-phase';
14
16
  import { cleanInlineTitle } from '../shared';
17
+ import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
15
18
  import {
19
+ createConversationUiPath,
16
20
  createDeepLinks,
17
21
  createTextMessage,
18
22
  finalizeMessages,
23
+ isWithinUpdatedWindow,
19
24
  normalizeAssistantPhase,
20
25
  normalizeRole,
21
26
  } from './adapter-helpers';
@@ -26,10 +31,13 @@ import type {
26
31
  ConversationDetail,
27
32
  ConversationMessage,
28
33
  ConversationPathMatch,
34
+ DeleteConversationOptions,
29
35
  GetConversationOptions,
30
36
  ListConversationsForPathOptions,
31
37
  } from './types';
32
38
 
39
+ const OPENCODE_CONVERSATION_HYDRATION_CONCURRENCY = 4;
40
+
33
41
  const getDbPath = (options: { locations?: { opencodeDbPath?: string } }) =>
34
42
  options.locations?.opencodeDbPath ?? resolveOpenCodeDbPath();
35
43
 
@@ -38,7 +46,10 @@ const textPartToMessages = (
38
46
  finalTextPartIds: Set<string>,
39
47
  order: number,
40
48
  ): ConversationMessage[] => {
41
- const split = splitOpenCodeThinkTaggedText(part.text ?? '');
49
+ const split =
50
+ part.role === 'assistant'
51
+ ? splitOpenCodeThinkTaggedText(part.text ?? '')
52
+ : { reasoningBlocks: [], visibleText: part.text ?? '' };
42
53
  return [
43
54
  ...createTextMessage({
44
55
  createdAtMs: part.createdAtMs,
@@ -80,15 +91,27 @@ const partToMessages = (
80
91
  }
81
92
 
82
93
  if (part.type === 'tool') {
83
- return createTextMessage({
84
- createdAtMs: part.createdAtMs,
85
- id: part.partId,
86
- metadata: { callId: part.callId, status: part.status, toolName: part.toolName },
87
- order,
88
- phase: part.outputText ? 'tool_output' : 'tool_call',
89
- role: 'tool',
90
- text: part.outputText ?? part.argumentsText ?? part.title ?? part.toolName,
91
- });
94
+ const metadata = { callId: part.callId, status: part.status, toolName: part.toolName };
95
+ return [
96
+ ...createTextMessage({
97
+ createdAtMs: part.createdAtMs,
98
+ id: `${part.partId}:tool_call`,
99
+ metadata,
100
+ order,
101
+ phase: 'tool_call',
102
+ role: 'tool',
103
+ text: [part.toolName, part.argumentsText ?? part.title].filter(Boolean).join('\n'),
104
+ }),
105
+ ...createTextMessage({
106
+ createdAtMs: part.createdAtMs,
107
+ id: `${part.partId}:tool_output`,
108
+ metadata,
109
+ order,
110
+ phase: 'tool_output',
111
+ role: 'tool',
112
+ text: part.outputText,
113
+ }),
114
+ ];
92
115
  }
93
116
 
94
117
  return [];
@@ -109,7 +132,13 @@ const buildConversation = async (
109
132
  ): Promise<ConversationDetail> => {
110
133
  const transcript =
111
134
  loadedTranscript ??
112
- (options.includeMessages ? await readOpenCodeSessionTranscript(dbPath, session.sessionId) : null);
135
+ (options.includeMessages
136
+ ? await runWithTranscriptLoadLimit(() => readOpenCodeSessionTranscript(dbPath, session.sessionId), {
137
+ id: session.sessionId,
138
+ path: dbPath,
139
+ source: 'opencode-api',
140
+ })
141
+ : null);
113
142
  const allMessages = transcript ? transcriptToMessages(transcript) : [];
114
143
  const messages = options.includeMessages
115
144
  ? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
@@ -117,7 +146,11 @@ const buildConversation = async (
117
146
 
118
147
  return {
119
148
  createdAtMs: session.createdAtMs,
120
- deepLinks: createDeepLinks('opencode', session.sessionId, `/opencode-sessions/${session.sessionId}`),
149
+ deepLinks: createDeepLinks(
150
+ 'opencode',
151
+ session.sessionId,
152
+ createConversationUiPath('opencode-sessions', session.sessionId),
153
+ ),
121
154
  id: session.sessionId,
122
155
  matches,
123
156
  messageCount: options.includeMessages ? allMessages.length : session.messageCount,
@@ -147,10 +180,14 @@ const listOpenCodeConversationsForPath = async (options: ListConversationsForPat
147
180
  if (!match) {
148
181
  continue;
149
182
  }
150
- const sessions = await listOpenCodeSessionsForGroup(group.key, dbPath);
151
- for (const session of sessions) {
152
- conversations.push(await buildConversation(session, dbPath, [match], options));
153
- }
183
+ const sessions = (await listOpenCodeSessionsForGroup(group.key, dbPath)).filter((session) =>
184
+ isWithinUpdatedWindow(session.lastUpdatedAtMs, options),
185
+ );
186
+ conversations.push(
187
+ ...(await mapWithConcurrency(sessions, OPENCODE_CONVERSATION_HYDRATION_CONCURRENCY, (session) =>
188
+ buildConversation(session, dbPath, [match], options),
189
+ )),
190
+ );
154
191
  }
155
192
 
156
193
  return conversations;
@@ -158,7 +195,11 @@ const listOpenCodeConversationsForPath = async (options: ListConversationsForPat
158
195
 
159
196
  const getOpenCodeConversation = async (options: GetConversationOptions): Promise<ConversationDetail | null> => {
160
197
  const dbPath = getDbPath(options);
161
- const transcript = await readOpenCodeSessionTranscript(dbPath, options.id);
198
+ const transcript = await runWithTranscriptLoadLimit(() => readOpenCodeSessionTranscript(dbPath, options.id), {
199
+ id: options.id,
200
+ path: dbPath,
201
+ source: 'opencode-api',
202
+ });
162
203
  return transcript
163
204
  ? buildConversation(
164
205
  transcript.session,
@@ -173,7 +214,16 @@ const getOpenCodeConversation = async (options: GetConversationOptions): Promise
173
214
  : null;
174
215
  };
175
216
 
217
+ const deleteOpenCodeConversation = async (options: DeleteConversationOptions) => {
218
+ const result = await deleteOpenCodeSession(getDbPath(options), options.id);
219
+ return {
220
+ deletedFiles: [],
221
+ deletedIds: result.deletedSessionIds,
222
+ };
223
+ };
224
+
176
225
  export const opencodeConversationAdapter: ConversationAdapter = {
226
+ deleteConversation: deleteOpenCodeConversation,
177
227
  getConversation: getOpenCodeConversation,
178
228
  listConversationsForPath: listOpenCodeConversationsForPath,
179
229
  source: 'opencode',
@@ -1,20 +1,8 @@
1
1
  import { realpath } from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
3
+ import { expandHome } from '../shared';
4
4
  import type { ConversationPathMatch } from './types';
5
5
 
6
- const expandHome = (value: string) => {
7
- if (value === '~') {
8
- return os.homedir();
9
- }
10
-
11
- if (value.startsWith('~/') || value.startsWith('~\\')) {
12
- return path.join(os.homedir(), value.slice(2));
13
- }
14
-
15
- return value;
16
- };
17
-
18
6
  const trimTrailingSeparators = (value: string) => {
19
7
  const trimmed = value.replace(/[\\/]+$/u, '');
20
8
  return trimmed || value;
@@ -14,10 +14,13 @@ import type {
14
14
  import { resolveQoderGlobalStateDb, resolveQoderWorkspaceStorageDir } from '../qoder-exporter-types';
15
15
  import { getFinalQoderAssistantMessageEntryIds, getQoderMessagePhase } from '../qoder-transcript-phase';
16
16
  import { cleanInlineTitle } from '../shared';
17
+ import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
17
18
  import {
19
+ createConversationUiPath,
18
20
  createDeepLinks,
19
21
  createTextMessage,
20
22
  finalizeMessages,
23
+ isWithinUpdatedWindow,
21
24
  normalizeAssistantPhase,
22
25
  normalizeRole,
23
26
  toDateMs,
@@ -35,6 +38,11 @@ import type {
35
38
 
36
39
  const QODER_CONVERSATION_HYDRATION_CONCURRENCY = 4;
37
40
 
41
+ const getPartString = (part: QoderTranscriptPart, key: string): string | null => {
42
+ const value = part.raw[key];
43
+ return typeof value === 'string' && value.trim() ? value : null;
44
+ };
45
+
38
46
  const getQoderLocations = (options: {
39
47
  locations?: {
40
48
  qoderAcpSocketPath?: string;
@@ -49,20 +57,6 @@ const getQoderLocations = (options: {
49
57
  workspaceStorageDir: options.locations?.qoderWorkspaceStorageDir ?? resolveQoderWorkspaceStorageDir(),
50
58
  });
51
59
 
52
- const isWithinUpdatedWindow = (
53
- session: QoderSessionSummary,
54
- options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
55
- ): boolean => {
56
- const updatedAtMs = session.lastActiveAtMs ?? 0;
57
- if (options.updatedAfterMs !== undefined && updatedAtMs < options.updatedAfterMs) {
58
- return false;
59
- }
60
- if (options.updatedBeforeMs !== undefined && updatedAtMs > options.updatedBeforeMs) {
61
- return false;
62
- }
63
- return true;
64
- };
65
-
66
60
  const partToMessages = (
67
61
  entry: QoderTranscriptEntry,
68
62
  part: QoderTranscriptPart,
@@ -73,7 +67,11 @@ const partToMessages = (
73
67
  return createTextMessage({
74
68
  createdAtMs: toDateMs(entry.timestamp),
75
69
  id: `${entry.entryId}:${partIndex}`,
76
- metadata: { requestId: entry.requestId },
70
+ metadata: {
71
+ requestId: entry.requestId,
72
+ toolCallId: getPartString(part, 'toolCallId') ?? entry.entryId,
73
+ toolName: getPartString(part, 'toolName'),
74
+ },
77
75
  order: partIndex,
78
76
  phase: 'tool_call',
79
77
  role: 'tool',
@@ -85,7 +83,11 @@ const partToMessages = (
85
83
  return createTextMessage({
86
84
  createdAtMs: toDateMs(entry.timestamp),
87
85
  id: `${entry.entryId}:${partIndex}`,
88
- metadata: { requestId: entry.requestId },
86
+ metadata: {
87
+ requestId: entry.requestId,
88
+ toolCallId: getPartString(part, 'toolCallId'),
89
+ toolName: getPartString(part, 'toolName'),
90
+ },
89
91
  order: partIndex,
90
92
  phase: 'tool_output',
91
93
  role: 'tool',
@@ -117,18 +119,28 @@ const buildConversation = async (
117
119
  locations: ReturnType<typeof getQoderLocations>,
118
120
  matches: ConversationPathMatch[],
119
121
  options: Pick<ListConversationsForPathOptions, 'includeMessages' | 'messageSelector'>,
122
+ loadedTranscript: QoderSessionTranscript | null = null,
120
123
  ): Promise<ConversationDetail> => {
121
124
  const transcript = options.includeMessages
122
- ? await readQoderSessionTranscript(
123
- locations.globalStateDb,
124
- locations.workspaceStorageDir,
125
- session.sessionId,
126
- locations.cliProjectsDir,
125
+ ? (loadedTranscript ??
126
+ (await runWithTranscriptLoadLimit(
127
+ () =>
128
+ readQoderSessionTranscript(
129
+ locations.globalStateDb,
130
+ locations.workspaceStorageDir,
131
+ session.sessionId,
132
+ locations.cliProjectsDir,
133
+ {
134
+ acpSocketPath: locations.acpSocketPath,
135
+ enableAcp: locations.acpSocketPath ? true : undefined,
136
+ },
137
+ ),
127
138
  {
128
- acpSocketPath: locations.acpSocketPath,
129
- enableAcp: locations.acpSocketPath ? true : undefined,
139
+ id: session.sessionId,
140
+ path: session.sourceStatePath ?? locations.globalStateDb,
141
+ source: 'qoder-api',
130
142
  },
131
- )
143
+ )))
132
144
  : null;
133
145
  const allMessages = transcript ? transcriptToMessages(transcript) : [];
134
146
  const messages = options.includeMessages
@@ -144,7 +156,11 @@ const buildConversation = async (
144
156
 
145
157
  return {
146
158
  createdAtMs: session.createdAtMs,
147
- deepLinks: createDeepLinks('qoder', session.sessionId, `/qoder-sessions/${session.sessionId}`),
159
+ deepLinks: createDeepLinks(
160
+ 'qoder',
161
+ session.sessionId,
162
+ createConversationUiPath('qoder-sessions', session.sessionId),
163
+ ),
148
164
  id: session.sessionId,
149
165
  matches,
150
166
  messageCount: options.includeMessages ? allMessages.length : session.messageCount,
@@ -190,7 +206,7 @@ const listQoderConversationsForPath = async (options: ListConversationsForPathOp
190
206
  locations.workspaceStorageDir,
191
207
  );
192
208
  for (const session of sessions) {
193
- if (!isWithinUpdatedWindow(session, options)) {
209
+ if (!isWithinUpdatedWindow(session.lastActiveAtMs, options)) {
194
210
  continue;
195
211
  }
196
212
 
@@ -205,21 +221,35 @@ const listQoderConversationsForPath = async (options: ListConversationsForPathOp
205
221
 
206
222
  const getQoderConversation = async (options: GetConversationOptions): Promise<ConversationDetail | null> => {
207
223
  const locations = getQoderLocations(options);
208
- const transcript = await readQoderSessionTranscript(
209
- locations.globalStateDb,
210
- locations.workspaceStorageDir,
211
- options.id,
212
- locations.cliProjectsDir,
224
+ const transcript = await runWithTranscriptLoadLimit(
225
+ () =>
226
+ readQoderSessionTranscript(
227
+ locations.globalStateDb,
228
+ locations.workspaceStorageDir,
229
+ options.id,
230
+ locations.cliProjectsDir,
231
+ {
232
+ acpSocketPath: locations.acpSocketPath,
233
+ enableAcp: locations.acpSocketPath ? true : undefined,
234
+ },
235
+ ),
213
236
  {
214
- acpSocketPath: locations.acpSocketPath,
215
- enableAcp: locations.acpSocketPath ? true : undefined,
237
+ id: options.id,
238
+ path: locations.globalStateDb,
239
+ source: 'qoder-api',
216
240
  },
217
241
  );
218
242
  return transcript
219
- ? buildConversation(transcript.session, locations, [], {
220
- includeMessages: true,
221
- messageSelector: options.messageSelector ?? 'all',
222
- })
243
+ ? buildConversation(
244
+ transcript.session,
245
+ locations,
246
+ [],
247
+ {
248
+ includeMessages: true,
249
+ messageSelector: options.messageSelector ?? 'all',
250
+ },
251
+ transcript,
252
+ )
223
253
  : null;
224
254
  };
225
255
 
@@ -1,6 +1,7 @@
1
1
  export const CONVERSATION_SOURCES = [
2
2
  'codex',
3
3
  'claude-code',
4
+ 'grok',
4
5
  'kiro',
5
6
  'qoder',
6
7
  'cursor',
@@ -77,6 +78,7 @@ export type ConversationDataLocations = {
77
78
  claudeCodeProjectsDir?: string;
78
79
  codexDbPath?: string;
79
80
  cursorUserDir?: string;
81
+ grokSessionsDir?: string;
80
82
  kiroWorkspaceSessionsDir?: string;
81
83
  opencodeDbPath?: string;
82
84
  qoderAcpSocketPath?: string;
@@ -104,12 +106,53 @@ export type GetConversationOptions = {
104
106
  source: ConversationSource;
105
107
  };
106
108
 
109
+ export type DeleteConversationOptions = {
110
+ id: string;
111
+ locations?: ConversationDataLocations;
112
+ source: ConversationSource;
113
+ };
114
+
115
+ export type DeleteConversationResult = {
116
+ deletedFiles: string[];
117
+ deletedIds: string[];
118
+ };
119
+
120
+ export type ConversationIdSetOptions = {
121
+ ids: string[];
122
+ locations?: ConversationDataLocations;
123
+ source: ConversationSource;
124
+ };
125
+
126
+ export type DeleteConversationsOptions = ConversationIdSetOptions;
127
+
128
+ export type DeleteConversationItemResult = DeleteConversationResult & {
129
+ deleted: boolean;
130
+ id: string;
131
+ };
132
+
133
+ export type DeleteConversationsResult = DeleteConversationResult & {
134
+ missingIds: string[];
135
+ results: DeleteConversationItemResult[];
136
+ };
137
+
138
+ export type ExportConversationsZipOptions = ConversationIdSetOptions & {
139
+ messageSelector?: ConversationMessageSelector;
140
+ outputFormat?: 'md';
141
+ };
142
+
143
+ export type ConversationZipDownload = {
144
+ blob: Blob;
145
+ fileName: string;
146
+ mimeType: 'application/zip';
147
+ };
148
+
107
149
  export type ResolvedConversationRef = {
108
150
  id: string;
109
151
  source: ConversationSource;
110
152
  };
111
153
 
112
154
  export type ConversationAdapter = {
155
+ deleteConversation?: (options: DeleteConversationOptions) => Promise<DeleteConversationResult>;
113
156
  getConversation: (options: GetConversationOptions) => Promise<ConversationDetail | null>;
114
157
  listConversationsForPath: (options: ListConversationsForPathOptions) => Promise<ConversationDetail[]>;
115
158
  source: ConversationSource;
@@ -0,0 +1,75 @@
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 { resolveUniqueExportFileBaseName, sanitizeExportFileName } from './ui-export-archive';
6
+ import { zipExportDirectory } from './ui-export-zip';
7
+
8
+ type ConversationMarkdownZipEntry = {
9
+ fallbackBaseName: string;
10
+ markdown: string;
11
+ title: string | null;
12
+ };
13
+
14
+ type ConversationMarkdownZipOptions = {
15
+ entries: ConversationMarkdownZipEntry[];
16
+ fileBaseName: string;
17
+ };
18
+
19
+ const EXPORT_BASE_NAME_BYTE_LIMIT = 120;
20
+
21
+ const truncateUtf8 = (value: string, maxBytes: number) => {
22
+ let bytes = 0;
23
+ let result = '';
24
+ for (const character of value) {
25
+ const characterBytes = Buffer.byteLength(character);
26
+ if (bytes + characterBytes > maxBytes) {
27
+ break;
28
+ }
29
+ bytes += characterBytes;
30
+ result += character;
31
+ }
32
+ return result;
33
+ };
34
+
35
+ export type ConversationMarkdownZip = {
36
+ blob: Blob;
37
+ fileName: string;
38
+ mimeType: 'application/zip';
39
+ };
40
+
41
+ const toSafeFileBaseName = (value: string | null, fallback: string) => {
42
+ const sanitized = sanitizeExportFileName(value?.trim() || '') || sanitizeExportFileName(fallback) || 'conversation';
43
+ return truncateUtf8(sanitized, EXPORT_BASE_NAME_BYTE_LIMIT) || 'conversation';
44
+ };
45
+
46
+ export const createConversationMarkdownZip = async ({
47
+ entries,
48
+ fileBaseName,
49
+ }: ConversationMarkdownZipOptions): Promise<ConversationMarkdownZip> => {
50
+ if (entries.length === 0) {
51
+ throw new Error('No conversations selected for export');
52
+ }
53
+
54
+ const safeBaseName = toSafeFileBaseName(fileBaseName, 'conversations');
55
+ const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${safeBaseName}-`));
56
+ const zipPath = path.join(os.tmpdir(), `${safeBaseName}-${randomUUID()}.zip`);
57
+ const usedBaseNames = new Map<string, number>();
58
+
59
+ try {
60
+ for (const entry of entries) {
61
+ const entryBaseName = toSafeFileBaseName(entry.title, entry.fallbackBaseName);
62
+ const fileBaseNameForEntry = resolveUniqueExportFileBaseName(entryBaseName, usedBaseNames);
63
+ await Bun.write(path.join(workspaceDir, `${fileBaseNameForEntry}.md`), entry.markdown);
64
+ }
65
+
66
+ await zipExportDirectory(workspaceDir, zipPath);
67
+ return {
68
+ blob: new Blob([await Bun.file(zipPath).arrayBuffer()], { type: 'application/zip' }),
69
+ fileName: `${safeBaseName}.zip`,
70
+ mimeType: 'application/zip',
71
+ };
72
+ } finally {
73
+ await Promise.all([rm(workspaceDir, { force: true, recursive: true }), rm(zipPath, { force: true })]);
74
+ }
75
+ };