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,4 +1,5 @@
1
1
  import {
2
+ CodexThreadNotFoundError,
2
3
  deleteCodexThread,
3
4
  getThreadBrowseData,
4
5
  listScopedThreads,
@@ -9,14 +10,22 @@ import { parseCodexTranscriptFile } from '../codex-thread-parser';
9
10
  import type { ThreadRow } from '../codex-thread-types';
10
11
  import { cleanInlineTitle } from '../shared';
11
12
  import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
13
+ import {
14
+ createConversationUiPath,
15
+ createDeepLinks,
16
+ createTextMessage,
17
+ finalizeMessages,
18
+ isWithinUpdatedWindow,
19
+ normalizeAssistantPhase,
20
+ normalizeRole,
21
+ toDateMs,
22
+ } from './adapter-helpers';
12
23
  import { selectConversationMessages } from './message-selector';
13
24
  import { getConversationPathMatch } from './path-match';
14
25
  import type {
15
26
  ConversationAdapter,
16
27
  ConversationDetail,
17
28
  ConversationMessage,
18
- ConversationMessagePhase,
19
- ConversationMessageRole,
20
29
  ConversationPathMatch,
21
30
  DeleteConversationOptions,
22
31
  GetConversationOptions,
@@ -35,36 +44,6 @@ const toCreatedAtMs = (thread: ThreadRow) => {
35
44
  return thread.created_at_ms ?? thread.created_at * 1000;
36
45
  };
37
46
 
38
- const buildCodexDeepLinks = (id: string) => ({
39
- native: `codex://threads/${id}`,
40
- spiracha: `spiracha://conversation/codex/${id}`,
41
- ui: `/threads/${id}`,
42
- });
43
-
44
- const normalizeRole = (role: string): ConversationMessageRole => {
45
- if (role === 'assistant' || role === 'user' || role === 'system') {
46
- return role;
47
- }
48
-
49
- return 'unknown';
50
- };
51
-
52
- const normalizeMessagePhase = (event: MessageEvent): ConversationMessagePhase => {
53
- if (event.role !== 'assistant') {
54
- return 'unknown';
55
- }
56
-
57
- if (event.phase === 'final_answer' || event.phase === 'final') {
58
- return 'final_answer';
59
- }
60
-
61
- if (event.phase === 'commentary' || event.isHiddenByDefault) {
62
- return 'commentary';
63
- }
64
-
65
- return 'final_answer';
66
- };
67
-
68
47
  const toMessageEventMessage = (event: MessageEvent): ConversationMessage | null => {
69
48
  const text = event.text.trim();
70
49
  if (!text) {
@@ -72,14 +51,17 @@ const toMessageEventMessage = (event: MessageEvent): ConversationMessage | null
72
51
  }
73
52
 
74
53
  return {
75
- createdAtMs: event.timestamp ? Date.parse(event.timestamp) : null,
54
+ createdAtMs: toDateMs(event.timestamp),
76
55
  id: `codex:${event.sequence}`,
77
56
  metadata: {
78
57
  model: event.model,
79
58
  variant: event.variant,
80
59
  },
81
60
  order: event.sequence,
82
- phase: normalizeMessagePhase(event),
61
+ phase:
62
+ event.role === 'assistant'
63
+ ? normalizeAssistantPhase(event.isHiddenByDefault ? 'commentary' : event.phase, 'unknown')
64
+ : 'unknown',
83
65
  role: normalizeRole(event.role),
84
66
  text,
85
67
  };
@@ -87,37 +69,41 @@ const toMessageEventMessage = (event: MessageEvent): ConversationMessage | null
87
69
 
88
70
  const toToolMessage = (event: ThreadEvent): ConversationMessage | null => {
89
71
  if (event.kind === 'tool_call') {
90
- return {
91
- createdAtMs: event.timestamp ? Date.parse(event.timestamp) : null,
92
- id: `codex:${event.sequence}`,
93
- metadata: {
94
- callId: event.callId,
95
- command: event.command,
96
- name: event.name,
97
- workdir: event.workdir,
98
- },
99
- order: event.sequence,
100
- phase: 'tool_call',
101
- role: 'tool',
102
- text: event.command ?? event.name,
103
- };
72
+ return (
73
+ createTextMessage({
74
+ createdAtMs: toDateMs(event.timestamp),
75
+ id: `codex:${event.sequence}`,
76
+ metadata: {
77
+ callId: event.callId,
78
+ command: event.command,
79
+ name: event.name,
80
+ workdir: event.workdir,
81
+ },
82
+ order: event.sequence,
83
+ phase: 'tool_call',
84
+ role: 'tool',
85
+ text: event.command || event.name,
86
+ })[0] ?? null
87
+ );
104
88
  }
105
89
 
106
90
  if (event.kind === 'tool_output') {
107
91
  const text = event.summary || event.outputText;
108
- return {
109
- createdAtMs: event.timestamp ? Date.parse(event.timestamp) : null,
110
- id: `codex:${event.sequence}`,
111
- metadata: {
112
- callId: event.callId,
113
- exitCode: event.exitCode,
114
- wallTime: event.wallTime,
115
- },
116
- order: event.sequence,
117
- phase: 'tool_output',
118
- role: 'tool',
119
- text,
120
- };
92
+ return (
93
+ createTextMessage({
94
+ createdAtMs: toDateMs(event.timestamp),
95
+ id: `codex:${event.sequence}`,
96
+ metadata: {
97
+ callId: event.callId,
98
+ exitCode: event.exitCode,
99
+ wallTime: event.wallTime,
100
+ },
101
+ order: event.sequence,
102
+ phase: 'tool_output',
103
+ role: 'tool',
104
+ text,
105
+ })[0] ?? null
106
+ );
121
107
  }
122
108
 
123
109
  return null;
@@ -125,6 +111,10 @@ const toToolMessage = (event: ThreadEvent): ConversationMessage | null => {
125
111
 
126
112
  const toConversationMessage = (event: ThreadEvent): ConversationMessage | null => {
127
113
  if (event.kind === 'message') {
114
+ if (event.isHiddenByDefault) {
115
+ return null;
116
+ }
117
+
128
118
  return toMessageEventMessage(event);
129
119
  }
130
120
 
@@ -132,7 +122,7 @@ const toConversationMessage = (event: ThreadEvent): ConversationMessage | null =
132
122
  const text = event.summary.join('\n').trim();
133
123
  return text
134
124
  ? {
135
- createdAtMs: event.timestamp ? Date.parse(event.timestamp) : null,
125
+ createdAtMs: toDateMs(event.timestamp),
136
126
  id: `codex:${event.sequence}`,
137
127
  metadata: {
138
128
  hasEncryptedContent: event.hasEncryptedContent,
@@ -149,22 +139,33 @@ const toConversationMessage = (event: ThreadEvent): ConversationMessage | null =
149
139
  };
150
140
 
151
141
  const readCodexMessages = async (thread: ThreadRow): Promise<ConversationMessage[]> => {
152
- const transcript = await runWithTranscriptLoadLimit(
153
- () =>
154
- parseCodexTranscriptFile(thread.rollout_path, {
155
- includeRaw: false,
156
- }),
157
- {
158
- id: thread.id,
159
- path: thread.rollout_path,
160
- source: 'codex-api',
161
- },
162
- );
142
+ let transcript: Awaited<ReturnType<typeof parseCodexTranscriptFile>>;
143
+ try {
144
+ transcript = await runWithTranscriptLoadLimit(
145
+ () =>
146
+ parseCodexTranscriptFile(thread.rollout_path, {
147
+ includeRaw: false,
148
+ }),
149
+ {
150
+ id: thread.id,
151
+ path: thread.rollout_path,
152
+ source: 'codex-api',
153
+ },
154
+ );
155
+ } catch (error) {
156
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
157
+ return [];
158
+ }
163
159
 
164
- return transcript.events.flatMap((event) => {
165
- const message = toConversationMessage(event);
166
- return message ? [message] : [];
167
- });
160
+ throw error;
161
+ }
162
+
163
+ return finalizeMessages(
164
+ transcript.events.flatMap((event) => {
165
+ const message = toConversationMessage(event);
166
+ return message ? [message] : [];
167
+ }),
168
+ );
168
169
  };
169
170
 
170
171
  const buildCodexConversation = async (
@@ -179,7 +180,12 @@ const buildCodexConversation = async (
179
180
 
180
181
  return {
181
182
  createdAtMs: toCreatedAtMs(thread),
182
- deepLinks: buildCodexDeepLinks(thread.id),
183
+ deepLinks: createDeepLinks(
184
+ 'codex',
185
+ thread.id,
186
+ createConversationUiPath('threads', thread.id),
187
+ `codex://threads/${encodeURIComponent(thread.id)}`,
188
+ ),
183
189
  id: thread.id,
184
190
  matches,
185
191
  messageCount: options.includeMessages ? allMessages.length : null,
@@ -221,8 +227,13 @@ const listCodexConversationsForPath = async (
221
227
  options: ListConversationsForPathOptions,
222
228
  ): Promise<ConversationDetail[]> => {
223
229
  const dbPath = getCodexDbPath(options);
230
+ if (!(await Bun.file(dbPath).exists())) {
231
+ return [];
232
+ }
224
233
  const threads = listScopedThreads(dbPath, null);
225
- const matchedThreads = await filterThreadsForPath(threads, options.cwd);
234
+ const matchedThreads = (await filterThreadsForPath(threads, options.cwd)).filter(({ thread }) =>
235
+ isWithinUpdatedWindow(toTimestampMs(thread), options),
236
+ );
226
237
 
227
238
  return Promise.all(
228
239
  matchedThreads.map(({ matches, thread }) =>
@@ -240,7 +251,7 @@ const getCodexConversation = async (options: GetConversationOptions): Promise<Co
240
251
  try {
241
252
  browseData = getThreadBrowseData(dbPath, options.id);
242
253
  } catch (error) {
243
- if (error instanceof Error && /not found/i.test(error.message)) {
254
+ if (error instanceof CodexThreadNotFoundError) {
244
255
  return null;
245
256
  }
246
257
 
@@ -12,9 +12,16 @@ import type {
12
12
  } from '../cursor-exporter-types';
13
13
  import { getCursorGlobalDbPath, resolveCursorUserDir } from '../cursor-exporter-types';
14
14
  import { collectCursorThreadsForDeletion, isCursorRunning, pruneCursorThreads } from '../cursor-recovery';
15
+ import { getCursorTextBubblePhase, getFinalCursorAssistantTextBubbleIds } from '../cursor-transcript-phase';
15
16
  import { cleanInlineTitle } from '../shared';
16
17
  import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
17
- import { createDeepLinks, createTextMessage, finalizeMessages } from './adapter-helpers';
18
+ import {
19
+ createConversationUiPath,
20
+ createDeepLinks,
21
+ createTextMessage,
22
+ finalizeMessages,
23
+ isWithinUpdatedWindow,
24
+ } from './adapter-helpers';
18
25
  import { selectConversationMessages } from './message-selector';
19
26
  import { getFirstConversationPathMatch } from './path-match';
20
27
  import type {
@@ -32,21 +39,11 @@ const CURSOR_CONVERSATION_HYDRATION_CONCURRENCY = 4;
32
39
  const getUserDir = (options: { locations?: { cursorUserDir?: string } }) =>
33
40
  options.locations?.cursorUserDir ?? resolveCursorUserDir();
34
41
 
35
- const isWithinUpdatedWindow = (
36
- thread: CursorThreadSummary,
37
- options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
38
- ): boolean => {
39
- const updatedAtMs = thread.lastUpdatedAtMs ?? 0;
40
- if (options.updatedAfterMs !== undefined && updatedAtMs < options.updatedAfterMs) {
41
- return false;
42
- }
43
- if (options.updatedBeforeMs !== undefined && updatedAtMs > options.updatedBeforeMs) {
44
- return false;
45
- }
46
- return true;
47
- };
48
-
49
- const bubbleToMessages = (bubble: CursorBubble, order: number): ConversationMessage[] => {
42
+ const bubbleToMessages = (
43
+ bubble: CursorBubble,
44
+ finalAssistantTextBubbleIds: Set<string>,
45
+ order: number,
46
+ ): ConversationMessage[] => {
50
47
  const thinking = createTextMessage({
51
48
  createdAtMs: bubble.createdAtMs,
52
49
  id: `${bubble.bubbleId}:thinking`,
@@ -59,27 +56,41 @@ const bubbleToMessages = (bubble: CursorBubble, order: number): ConversationMess
59
56
  createdAtMs: bubble.createdAtMs,
60
57
  id: bubble.bubbleId,
61
58
  order,
62
- phase: bubble.kind === 'assistant' ? 'final_answer' : 'unknown',
59
+ phase: getCursorTextBubblePhase(bubble, finalAssistantTextBubbleIds) ?? 'unknown',
63
60
  role: bubble.kind === 'assistant' ? 'assistant' : bubble.kind === 'user' ? 'user' : 'unknown',
64
61
  text: bubble.text,
65
62
  });
66
- const tool = bubble.toolCall
63
+ const toolCall = bubble.toolCall
64
+ ? createTextMessage({
65
+ createdAtMs: bubble.createdAtMs,
66
+ id: `${bubble.bubbleId}:tool_call`,
67
+ metadata: { callId: bubble.toolCall.callId, status: bubble.toolCall.status },
68
+ order,
69
+ phase: 'tool_call',
70
+ role: 'tool',
71
+ text: [bubble.toolCall.name, bubble.toolCall.argumentsText].filter(Boolean).join('\n'),
72
+ })
73
+ : [];
74
+ const toolOutput = bubble.toolCall
67
75
  ? createTextMessage({
68
76
  createdAtMs: bubble.createdAtMs,
69
- id: `${bubble.bubbleId}:tool`,
77
+ id: `${bubble.bubbleId}:tool_output`,
70
78
  metadata: { callId: bubble.toolCall.callId, status: bubble.toolCall.status },
71
79
  order,
72
- phase: bubble.toolCall.resultText ? 'tool_output' : 'tool_call',
80
+ phase: 'tool_output',
73
81
  role: 'tool',
74
- text: bubble.toolCall.resultText ?? bubble.toolCall.argumentsText ?? bubble.toolCall.name,
82
+ text: bubble.toolCall.resultText,
75
83
  })
76
84
  : [];
77
85
 
78
- return [...thinking, ...text, ...tool];
86
+ return [...thinking, ...text, ...toolCall, ...toolOutput];
79
87
  };
80
88
 
81
89
  const transcriptToMessages = (transcript: CursorThreadTranscript) => {
82
- return finalizeMessages(transcript.bubbles.flatMap((bubble, order) => bubbleToMessages(bubble, order)));
90
+ const finalAssistantTextBubbleIds = getFinalCursorAssistantTextBubbleIds(transcript.bubbles);
91
+ return finalizeMessages(
92
+ transcript.bubbles.flatMap((bubble, order) => bubbleToMessages(bubble, finalAssistantTextBubbleIds, order)),
93
+ );
83
94
  };
84
95
 
85
96
  const getWorkspacePath = (group: CursorWorkspaceGroup) => {
@@ -111,7 +122,11 @@ const buildConversation = async (
111
122
 
112
123
  return {
113
124
  createdAtMs: thread.createdAtMs,
114
- deepLinks: createDeepLinks('cursor', thread.composerId, `/cursor-threads/${thread.composerId}`),
125
+ deepLinks: createDeepLinks(
126
+ 'cursor',
127
+ thread.composerId,
128
+ createConversationUiPath('cursor-threads', thread.composerId),
129
+ ),
115
130
  id: thread.composerId,
116
131
  matches,
117
132
  messageCount: options.includeMessages ? allMessages.length : thread.bubbleCount,
@@ -120,7 +135,6 @@ const buildConversation = async (
120
135
  bubbleBytes: thread.bubbleBytes,
121
136
  bucketId: thread.bucketId,
122
137
  mode: thread.mode,
123
- transcriptDirs: thread.transcriptDirs,
124
138
  },
125
139
  source: 'cursor',
126
140
  title: cleanInlineTitle(thread.name),
@@ -132,7 +146,7 @@ const buildConversation = async (
132
146
 
133
147
  const listCursorConversationsForPath = async (options: ListConversationsForPathOptions) => {
134
148
  const userDir = getUserDir(options);
135
- const groups = await listCursorWorkspaceGroups(userDir, { updatedAfterMs: options.updatedAfterMs });
149
+ const groups = await listCursorWorkspaceGroups(userDir);
136
150
  const candidates: { group: CursorWorkspaceGroup; match: ConversationPathMatch; thread: CursorThreadSummary }[] = [];
137
151
 
138
152
  for (const group of groups) {
@@ -142,10 +156,9 @@ const listCursorConversationsForPath = async (options: ListConversationsForPathO
142
156
  }
143
157
  const threads = await listCursorThreadsForGroup(group, userDir, {
144
158
  includeTranscriptDirs: false,
145
- updatedAfterMs: options.updatedAfterMs,
146
159
  });
147
160
  for (const thread of threads) {
148
- if (!isWithinUpdatedWindow(thread, options)) {
161
+ if (!isWithinUpdatedWindow(thread.lastUpdatedAtMs, options)) {
149
162
  continue;
150
163
  }
151
164
 
@@ -175,20 +188,21 @@ const getCursorConversation = async (options: GetConversationOptions): Promise<C
175
188
  return null;
176
189
  };
177
190
 
178
- const deleteCursorConversation = async (options: DeleteConversationOptions) => {
191
+ export const deleteCursorConversation = async (
192
+ options: DeleteConversationOptions,
193
+ checkCursorRunning: () => Promise<boolean> = isCursorRunning,
194
+ ) => {
179
195
  const userDir = getUserDir(options);
180
- if (!options.locations?.cursorUserDir && (await isCursorRunning())) {
196
+ if (await checkCursorRunning()) {
181
197
  throw new Error(
182
198
  'Quit Cursor before deleting. It rewrites chat history on exit, which can resurrect deleted threads.',
183
199
  );
184
200
  }
185
201
 
186
- const existing = await getCursorConversation(options);
187
- if (!existing) {
202
+ const threads = await collectCursorThreadsForDeletion([options.id], userDir);
203
+ if (threads.length === 0) {
188
204
  return { deletedFiles: [], deletedIds: [] };
189
205
  }
190
-
191
- const threads = await collectCursorThreadsForDeletion([options.id], userDir);
192
206
  const deletedFiles = threads.flatMap((thread) => thread.transcriptDirs);
193
207
  const result = await pruneCursorThreads(threads, true, userDir);
194
208
  return {
@@ -1,6 +1,7 @@
1
+ import { mapWithConcurrency } from '../concurrency';
1
2
  import {
2
3
  deleteGrokSession,
3
- listGrokSessionsForGroup,
4
+ listGrokSessionTranscriptsForGroup,
4
5
  listGrokWorkspaceGroups,
5
6
  readGrokSessionTranscript,
6
7
  resolveGrokSessionsDir,
@@ -15,9 +16,11 @@ import { getFinalGrokAssistantTextPartIds, getGrokTextPartPhase } from '../grok-
15
16
  import { cleanInlineTitle } from '../shared';
16
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
  } from './adapter-helpers';
@@ -33,6 +36,8 @@ import type {
33
36
  ListConversationsForPathOptions,
34
37
  } from './types';
35
38
 
39
+ const GROK_CONVERSATION_HYDRATION_CONCURRENCY = 4;
40
+
36
41
  const getSessionsDir = (options: { locations?: { grokSessionsDir?: string } }) =>
37
42
  options.locations?.grokSessionsDir ?? resolveGrokSessionsDir();
38
43
 
@@ -129,7 +134,11 @@ const buildConversation = async (
129
134
 
130
135
  return {
131
136
  createdAtMs: session.createdAtMs,
132
- deepLinks: createDeepLinks('grok', session.sessionId, `/grok-sessions/${session.sessionId}`),
137
+ deepLinks: createDeepLinks(
138
+ 'grok',
139
+ session.sessionId,
140
+ createConversationUiPath('grok-sessions', session.sessionId),
141
+ ),
133
142
  id: session.sessionId,
134
143
  matches,
135
144
  messageCount: options.includeMessages ? allMessages.length : session.messageCount,
@@ -161,10 +170,14 @@ const listGrokConversationsForPath = async (options: ListConversationsForPathOpt
161
170
  if (!match) {
162
171
  continue;
163
172
  }
164
- const sessions = await listGrokSessionsForGroup(group.key, sessionsDir);
165
- for (const session of sessions) {
166
- conversations.push(await buildConversation(session, sessionsDir, [match], options));
167
- }
173
+ const transcripts = (await listGrokSessionTranscriptsForGroup(group.key, sessionsDir)).filter((transcript) =>
174
+ isWithinUpdatedWindow(transcript.session.lastActiveAtMs, options),
175
+ );
176
+ conversations.push(
177
+ ...(await mapWithConcurrency(transcripts, GROK_CONVERSATION_HYDRATION_CONCURRENCY, (transcript) =>
178
+ buildConversation(transcript.session, sessionsDir, [match], options, transcript),
179
+ )),
180
+ );
168
181
  }
169
182
 
170
183
  return conversations;
@@ -104,7 +104,7 @@ const getEnabledSources = (sources: ListConversationsForPathOptions['sources']):
104
104
  return SOURCE_INFOS.map((sourceInfo) => sourceInfo.source);
105
105
  }
106
106
 
107
- return sources;
107
+ return [...new Set(sources)];
108
108
  };
109
109
 
110
110
  const isAllSourcesRequest = (sources: ListConversationsForPathOptions['sources']) => !sources || sources === 'all';
@@ -118,8 +118,16 @@ const decodeCursor = (cursor: string | null | undefined) => {
118
118
  return 0;
119
119
  }
120
120
 
121
- const parsed = Number.parseInt(Buffer.from(cursor, 'base64url').toString('utf8'), 10);
122
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
121
+ const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
122
+ if (!/^\d+$/u.test(decoded)) {
123
+ throw new Error('Invalid conversation pagination cursor.');
124
+ }
125
+
126
+ const parsed = Number(decoded);
127
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
128
+ throw new Error('Invalid conversation pagination cursor.');
129
+ }
130
+ return parsed;
123
131
  };
124
132
 
125
133
  const encodeCursor = (offset: number) => Buffer.from(String(offset), 'utf8').toString('base64url');
@@ -157,7 +165,7 @@ const sortConversations = (conversations: Awaited<ReturnType<ConversationAdapter
157
165
  );
158
166
  };
159
167
 
160
- export const listConversationSources = async (): Promise<ConversationSourceInfo[]> => SOURCE_INFOS;
168
+ export const listConversationSources = async (): Promise<ConversationSourceInfo[]> => [...SOURCE_INFOS];
161
169
 
162
170
  const listSourceConversationsForPath = async (
163
171
  source: ConversationSource,
@@ -175,6 +183,10 @@ const listSourceConversationsForPath = async (
175
183
  if (!ignoreSourceFailures) {
176
184
  throw error;
177
185
  }
186
+ console.warn(`[spiracha:conversation-data] skipped ${source} during all-source collection`, {
187
+ error: error instanceof Error ? error.message : String(error),
188
+ source,
189
+ });
178
190
  return [];
179
191
  }
180
192
  };
@@ -314,14 +326,21 @@ const refFromPathSegmentAt = (segments: string[], index: number): ResolvedConver
314
326
  };
315
327
 
316
328
  const refFromPathSegments = (segments: string[]): ResolvedConversationRef | null => {
317
- for (let index = 0; index < segments.length - 1; index += 1) {
318
- const ref = refFromPathSegmentAt(segments, index);
319
- if (ref) {
320
- return ref;
321
- }
329
+ if (
330
+ segments[0] === 'api' &&
331
+ segments[1] === 'v1' &&
332
+ segments[2] === 'conversations' &&
333
+ (segments.length === 5 || (segments.length === 6 && segments[5] === 'export'))
334
+ ) {
335
+ return refFromPathSegmentAt(segments, 2);
322
336
  }
323
337
 
324
- return null;
338
+ if (segments[0] === 'app') {
339
+ return refFromPathSegments(segments.slice(1));
340
+ }
341
+
342
+ const expectedLength = segments[0] === 'conversations' ? 3 : 2;
343
+ return segments.length === expectedLength ? refFromPathSegmentAt(segments, 0) : null;
325
344
  };
326
345
 
327
346
  const parseUrlRef = (ref: string): ResolvedConversationRef | null => {
@@ -1,3 +1,4 @@
1
+ import { mapWithConcurrency } from '../concurrency';
1
2
  import {
2
3
  deleteKiroSession,
3
4
  listKiroSessionsForGroup,
@@ -15,9 +16,11 @@ import { getFinalKiroAssistantMessageEntryIds, getKiroMessagePhase } from '../ki
15
16
  import { cleanInlineTitle } from '../shared';
16
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,
@@ -34,6 +37,8 @@ import type {
34
37
  ListConversationsForPathOptions,
35
38
  } from './types';
36
39
 
40
+ const KIRO_CONVERSATION_HYDRATION_CONCURRENCY = 4;
41
+
37
42
  const getSessionsDir = (options: { locations?: { kiroWorkspaceSessionsDir?: string } }) =>
38
43
  options.locations?.kiroWorkspaceSessionsDir ?? resolveKiroWorkspaceSessionsDir();
39
44
 
@@ -98,7 +103,11 @@ const buildConversation = async (
98
103
 
99
104
  return {
100
105
  createdAtMs: session.createdAtMs,
101
- deepLinks: createDeepLinks('kiro', session.sessionId, `/kiro-sessions/${session.sessionId}`),
106
+ deepLinks: createDeepLinks(
107
+ 'kiro',
108
+ session.sessionId,
109
+ createConversationUiPath('kiro-sessions', session.sessionId),
110
+ ),
102
111
  id: session.sessionId,
103
112
  matches,
104
113
  messageCount: options.includeMessages ? allMessages.length : session.messageCount,
@@ -128,10 +137,14 @@ const listKiroConversationsForPath = async (options: ListConversationsForPathOpt
128
137
  if (!match) {
129
138
  continue;
130
139
  }
131
- const sessions = await listKiroSessionsForGroup(group.key, sessionsDir);
132
- for (const session of sessions) {
133
- conversations.push(await buildConversation(session, sessionsDir, [match], options));
134
- }
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
+ );
135
148
  }
136
149
 
137
150
  return conversations;
@@ -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