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.
- package/AGENTS.md +27 -12
- package/README.md +42 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +36 -11
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +46 -3
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +23 -5
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +79 -74
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +13 -47
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +209 -11
- package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +185 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +16 -0
- package/apps/ui/src/lib/codex-server.ts +134 -52
- package/apps/ui/src/lib/cursor-server.ts +68 -53
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +168 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +97 -7
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +96 -7
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
- package/apps/ui/src/routeTree.gen.ts +341 -234
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
- package/apps/ui/src/routes/opencode.index.tsx +6 -2
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +37 -27
- package/src/client.ts +183 -5
- package/src/lib/antigravity-db.ts +424 -94
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +534 -42
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +18 -27
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +106 -23
- package/src/lib/codex-thread-parser.ts +149 -55
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +87 -79
- package/src/lib/concurrency.ts +56 -3
- package/src/lib/conversation-api.ts +410 -27
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
- package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
- package/src/lib/conversation-data/codex-adapter.ts +108 -73
- package/src/lib/conversation-data/cursor-adapter.ts +77 -29
- package/src/lib/conversation-data/grok-adapter.ts +223 -0
- package/src/lib/conversation-data/index.ts +105 -11
- package/src/lib/conversation-data/kiro-adapter.ts +59 -12
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +67 -17
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +67 -37
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +75 -0
- package/src/lib/cursor-db.ts +68 -28
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +1068 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +61 -0
- package/src/lib/grok-transcript.ts +167 -0
- package/src/lib/kiro-db.ts +187 -43
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +599 -60
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +63 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +87 -0
- package/src/lib/ui-cache.ts +185 -24
- package/src/lib/ui-export-archive.ts +58 -39
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- package/apps/ui/package.json +0 -65
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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(
|
|
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
|
-
|
|
118
|
-
|
|
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(
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
152
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
?
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
237
|
+
id: options.id,
|
|
238
|
+
path: locations.globalStateDb,
|
|
239
|
+
source: 'qoder-api',
|
|
216
240
|
},
|
|
217
241
|
);
|
|
218
242
|
return transcript
|
|
219
|
-
? buildConversation(
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
};
|