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.
- package/AGENTS.md +27 -12
- package/README.md +37 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +17 -10
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
- package/apps/ui/src/components/app-shell.tsx +45 -3
- package/apps/ui/src/components/data-table.tsx +17 -1
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +77 -62
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -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 +4 -1
- package/apps/ui/src/lib/antigravity-server.ts +70 -17
- package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
- package/apps/ui/src/lib/claude-code-server.ts +14 -4
- package/apps/ui/src/lib/codex-queries.ts +3 -3
- package/apps/ui/src/lib/codex-server.ts +3 -3
- package/apps/ui/src/lib/cursor-server.ts +41 -69
- 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/grok-server.ts +2 -3
- package/apps/ui/src/lib/kiro-server.ts +14 -2
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +13 -2
- 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-transcript-events.ts +1 -1
- 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 +1 -1
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
- package/apps/ui/src/routeTree.gen.ts +282 -282
- 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 +73 -19
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
- 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 +29 -16
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
- package/apps/ui/src/routes/grok.index.tsx +2 -2
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
- package/apps/ui/src/routes/opencode.index.tsx +8 -17
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +26 -17
- package/src/client.ts +61 -16
- package/src/lib/antigravity-db.ts +236 -82
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +45 -19
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +2 -7
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +88 -33
- package/src/lib/codex-thread-parser.ts +78 -35
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-renderer.ts +49 -61
- package/src/lib/concurrency.ts +15 -3
- package/src/lib/conversation-api.ts +34 -31
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
- package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
- package/src/lib/conversation-data/codex-adapter.ts +91 -80
- package/src/lib/conversation-data/cursor-adapter.ts +48 -34
- package/src/lib/conversation-data/grok-adapter.ts +19 -6
- package/src/lib/conversation-data/index.ts +29 -10
- package/src/lib/conversation-data/kiro-adapter.ts +18 -5
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +43 -15
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +23 -18
- package/src/lib/conversation-zip-export.ts +20 -2
- package/src/lib/cursor-db.ts +34 -23
- 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 +76 -34
- package/src/lib/grok-transcript-phase.ts +11 -2
- package/src/lib/grok-transcript.ts +15 -2
- package/src/lib/kiro-db.ts +161 -68
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/opencode-db.ts +4 -8
- package/src/lib/opencode-think-tags.ts +17 -3
- 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 +51 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +8 -3
- package/src/lib/ui-cache.ts +152 -17
- package/src/lib/ui-export-archive.ts +14 -55
- 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,3 +1,4 @@
|
|
|
1
|
+
import { mapWithConcurrency } from '../concurrency';
|
|
1
2
|
import {
|
|
2
3
|
deleteOpenCodeSession,
|
|
3
4
|
listOpenCodeSessionsForGroup,
|
|
@@ -15,9 +16,11 @@ import { getFinalOpenCodeAssistantTextPartIds, getOpenCodeTextPartPhase } from '
|
|
|
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 OPENCODE_CONVERSATION_HYDRATION_CONCURRENCY = 4;
|
|
40
|
+
|
|
36
41
|
const getDbPath = (options: { locations?: { opencodeDbPath?: string } }) =>
|
|
37
42
|
options.locations?.opencodeDbPath ?? resolveOpenCodeDbPath();
|
|
38
43
|
|
|
@@ -41,7 +46,10 @@ const textPartToMessages = (
|
|
|
41
46
|
finalTextPartIds: Set<string>,
|
|
42
47
|
order: number,
|
|
43
48
|
): ConversationMessage[] => {
|
|
44
|
-
const split =
|
|
49
|
+
const split =
|
|
50
|
+
part.role === 'assistant'
|
|
51
|
+
? splitOpenCodeThinkTaggedText(part.text ?? '')
|
|
52
|
+
: { reasoningBlocks: [], visibleText: part.text ?? '' };
|
|
45
53
|
return [
|
|
46
54
|
...createTextMessage({
|
|
47
55
|
createdAtMs: part.createdAtMs,
|
|
@@ -83,15 +91,27 @@ const partToMessages = (
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
if (part.type === 'tool') {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
];
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
return [];
|
|
@@ -126,7 +146,11 @@ const buildConversation = async (
|
|
|
126
146
|
|
|
127
147
|
return {
|
|
128
148
|
createdAtMs: session.createdAtMs,
|
|
129
|
-
deepLinks: createDeepLinks(
|
|
149
|
+
deepLinks: createDeepLinks(
|
|
150
|
+
'opencode',
|
|
151
|
+
session.sessionId,
|
|
152
|
+
createConversationUiPath('opencode-sessions', session.sessionId),
|
|
153
|
+
),
|
|
130
154
|
id: session.sessionId,
|
|
131
155
|
matches,
|
|
132
156
|
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
|
|
@@ -156,10 +180,14 @@ const listOpenCodeConversationsForPath = async (options: ListConversationsForPat
|
|
|
156
180
|
if (!match) {
|
|
157
181
|
continue;
|
|
158
182
|
}
|
|
159
|
-
const sessions = await listOpenCodeSessionsForGroup(group.key, dbPath)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
);
|
|
163
191
|
}
|
|
164
192
|
|
|
165
193
|
return conversations;
|
|
@@ -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;
|
|
@@ -16,9 +16,11 @@ import { getFinalQoderAssistantMessageEntryIds, getQoderMessagePhase } from '../
|
|
|
16
16
|
import { cleanInlineTitle } from '../shared';
|
|
17
17
|
import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
|
|
18
18
|
import {
|
|
19
|
+
createConversationUiPath,
|
|
19
20
|
createDeepLinks,
|
|
20
21
|
createTextMessage,
|
|
21
22
|
finalizeMessages,
|
|
23
|
+
isWithinUpdatedWindow,
|
|
22
24
|
normalizeAssistantPhase,
|
|
23
25
|
normalizeRole,
|
|
24
26
|
toDateMs,
|
|
@@ -36,6 +38,11 @@ import type {
|
|
|
36
38
|
|
|
37
39
|
const QODER_CONVERSATION_HYDRATION_CONCURRENCY = 4;
|
|
38
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
|
+
|
|
39
46
|
const getQoderLocations = (options: {
|
|
40
47
|
locations?: {
|
|
41
48
|
qoderAcpSocketPath?: string;
|
|
@@ -50,20 +57,6 @@ const getQoderLocations = (options: {
|
|
|
50
57
|
workspaceStorageDir: options.locations?.qoderWorkspaceStorageDir ?? resolveQoderWorkspaceStorageDir(),
|
|
51
58
|
});
|
|
52
59
|
|
|
53
|
-
const isWithinUpdatedWindow = (
|
|
54
|
-
session: QoderSessionSummary,
|
|
55
|
-
options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
|
|
56
|
-
): boolean => {
|
|
57
|
-
const updatedAtMs = session.lastActiveAtMs ?? 0;
|
|
58
|
-
if (options.updatedAfterMs !== undefined && updatedAtMs < options.updatedAfterMs) {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
if (options.updatedBeforeMs !== undefined && updatedAtMs > options.updatedBeforeMs) {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
return true;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
60
|
const partToMessages = (
|
|
68
61
|
entry: QoderTranscriptEntry,
|
|
69
62
|
part: QoderTranscriptPart,
|
|
@@ -74,7 +67,11 @@ const partToMessages = (
|
|
|
74
67
|
return createTextMessage({
|
|
75
68
|
createdAtMs: toDateMs(entry.timestamp),
|
|
76
69
|
id: `${entry.entryId}:${partIndex}`,
|
|
77
|
-
metadata: {
|
|
70
|
+
metadata: {
|
|
71
|
+
requestId: entry.requestId,
|
|
72
|
+
toolCallId: getPartString(part, 'toolCallId') ?? entry.entryId,
|
|
73
|
+
toolName: getPartString(part, 'toolName'),
|
|
74
|
+
},
|
|
78
75
|
order: partIndex,
|
|
79
76
|
phase: 'tool_call',
|
|
80
77
|
role: 'tool',
|
|
@@ -86,7 +83,11 @@ const partToMessages = (
|
|
|
86
83
|
return createTextMessage({
|
|
87
84
|
createdAtMs: toDateMs(entry.timestamp),
|
|
88
85
|
id: `${entry.entryId}:${partIndex}`,
|
|
89
|
-
metadata: {
|
|
86
|
+
metadata: {
|
|
87
|
+
requestId: entry.requestId,
|
|
88
|
+
toolCallId: getPartString(part, 'toolCallId'),
|
|
89
|
+
toolName: getPartString(part, 'toolName'),
|
|
90
|
+
},
|
|
90
91
|
order: partIndex,
|
|
91
92
|
phase: 'tool_output',
|
|
92
93
|
role: 'tool',
|
|
@@ -155,7 +156,11 @@ const buildConversation = async (
|
|
|
155
156
|
|
|
156
157
|
return {
|
|
157
158
|
createdAtMs: session.createdAtMs,
|
|
158
|
-
deepLinks: createDeepLinks(
|
|
159
|
+
deepLinks: createDeepLinks(
|
|
160
|
+
'qoder',
|
|
161
|
+
session.sessionId,
|
|
162
|
+
createConversationUiPath('qoder-sessions', session.sessionId),
|
|
163
|
+
),
|
|
159
164
|
id: session.sessionId,
|
|
160
165
|
matches,
|
|
161
166
|
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
|
|
@@ -201,7 +206,7 @@ const listQoderConversationsForPath = async (options: ListConversationsForPathOp
|
|
|
201
206
|
locations.workspaceStorageDir,
|
|
202
207
|
);
|
|
203
208
|
for (const session of sessions) {
|
|
204
|
-
if (!isWithinUpdatedWindow(session, options)) {
|
|
209
|
+
if (!isWithinUpdatedWindow(session.lastActiveAtMs, options)) {
|
|
205
210
|
continue;
|
|
206
211
|
}
|
|
207
212
|
|
|
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { resolveUniqueExportFileBaseName, sanitizeExportFileName
|
|
5
|
+
import { resolveUniqueExportFileBaseName, sanitizeExportFileName } from './ui-export-archive';
|
|
6
|
+
import { zipExportDirectory } from './ui-export-zip';
|
|
6
7
|
|
|
7
8
|
type ConversationMarkdownZipEntry = {
|
|
8
9
|
fallbackBaseName: string;
|
|
@@ -15,6 +16,22 @@ type ConversationMarkdownZipOptions = {
|
|
|
15
16
|
fileBaseName: string;
|
|
16
17
|
};
|
|
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
|
+
|
|
18
35
|
export type ConversationMarkdownZip = {
|
|
19
36
|
blob: Blob;
|
|
20
37
|
fileName: string;
|
|
@@ -22,7 +39,8 @@ export type ConversationMarkdownZip = {
|
|
|
22
39
|
};
|
|
23
40
|
|
|
24
41
|
const toSafeFileBaseName = (value: string | null, fallback: string) => {
|
|
25
|
-
|
|
42
|
+
const sanitized = sanitizeExportFileName(value?.trim() || '') || sanitizeExportFileName(fallback) || 'conversation';
|
|
43
|
+
return truncateUtf8(sanitized, EXPORT_BASE_NAME_BYTE_LIMIT) || 'conversation';
|
|
26
44
|
};
|
|
27
45
|
|
|
28
46
|
export const createConversationMarkdownZip = async ({
|
package/src/lib/cursor-db.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { constants, Database } from 'bun:sqlite';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { readdir, stat } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import {
|
|
@@ -19,7 +20,8 @@ import {
|
|
|
19
20
|
getCursorWorkspaceStorageDir,
|
|
20
21
|
resolveCursorUserDir,
|
|
21
22
|
} from './cursor-exporter-types';
|
|
22
|
-
import {
|
|
23
|
+
import { buildCursorBubbleKeyLikePattern, isSafeCursorComposerId } from './cursor-id';
|
|
24
|
+
import { asNumber, asObject, asString, type JsonValue, pathExists, toFileUri } from './shared';
|
|
23
25
|
|
|
24
26
|
type ComposerEntry = Record<string, JsonValue> & {
|
|
25
27
|
composerId?: string;
|
|
@@ -34,7 +36,7 @@ export const CURSOR_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | co
|
|
|
34
36
|
// the constructor never sees it). immutable=1 reads the main database file directly, which works
|
|
35
37
|
// whether or not Cursor is running and whether or not the WAL sidecars are present. The explicit URI
|
|
36
38
|
// flag keeps this portable across SQLite builds where URI filename parsing is not enabled globally.
|
|
37
|
-
export const getCursorReadonlyDbUri = (dbPath: string): string => {
|
|
39
|
+
export const getCursorReadonlyDbUri = (dbPath: string, immutable = true): string => {
|
|
38
40
|
const normalizedPath = dbPath.replace(/\\/gu, '/');
|
|
39
41
|
const absolutePath = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
40
42
|
const encodedPath = absolutePath
|
|
@@ -42,20 +44,12 @@ export const getCursorReadonlyDbUri = (dbPath: string): string => {
|
|
|
42
44
|
.map((segment) => (/^[A-Za-z]:$/u.test(segment) ? segment : encodeURIComponent(segment)))
|
|
43
45
|
.join('/');
|
|
44
46
|
|
|
45
|
-
return `file://${encodedPath}?immutable=1`;
|
|
47
|
+
return `file://${encodedPath}?${immutable ? 'immutable=1' : 'mode=ro'}`;
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
export const openCursorReadonlyDb = (dbPath: string): Database => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const pathExists = async (target: string): Promise<boolean> => {
|
|
53
|
-
try {
|
|
54
|
-
await stat(target);
|
|
55
|
-
return true;
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
51
|
+
const hasWalSidecars = existsSync(`${dbPath}-wal`) || existsSync(`${dbPath}-shm`);
|
|
52
|
+
return new Database(getCursorReadonlyDbUri(dbPath, !hasWalSidecars), CURSOR_READONLY_DB_OPEN_FLAGS);
|
|
59
53
|
};
|
|
60
54
|
|
|
61
55
|
const isMissingOrUnreadableCursorStoreError = (error: unknown): boolean => {
|
|
@@ -95,7 +89,12 @@ export const decodeCursorUri = (uri: string): string => {
|
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
if (uri.startsWith('file://')) {
|
|
98
|
-
|
|
92
|
+
const rawPath = uri.slice('file://'.length);
|
|
93
|
+
try {
|
|
94
|
+
return decodeURIComponent(rawPath);
|
|
95
|
+
} catch {
|
|
96
|
+
return rawPath;
|
|
97
|
+
}
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
return uri;
|
|
@@ -440,8 +439,10 @@ export const findCursorWorkspaceGroups = (groups: CursorWorkspaceGroup[], query:
|
|
|
440
439
|
|
|
441
440
|
const countBubbles = (db: Database, composerId: string): { count: number; bytes: number } => {
|
|
442
441
|
const row = db
|
|
443
|
-
.query(
|
|
444
|
-
|
|
442
|
+
.query(
|
|
443
|
+
`SELECT COUNT(*) AS count, COALESCE(SUM(length(value)), 0) AS bytes FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`,
|
|
444
|
+
)
|
|
445
|
+
.get(buildCursorBubbleKeyLikePattern(composerId)) as { count: number; bytes: number };
|
|
445
446
|
return { bytes: row.bytes, count: row.count };
|
|
446
447
|
};
|
|
447
448
|
|
|
@@ -449,6 +450,10 @@ export const findCursorTranscriptDirs = async (
|
|
|
449
450
|
composerId: string,
|
|
450
451
|
userDir = resolveCursorUserDir(),
|
|
451
452
|
): Promise<string[]> => {
|
|
453
|
+
if (!isSafeCursorComposerId(composerId)) {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
|
|
452
457
|
const projectsDir = getCursorProjectsDir(userDir);
|
|
453
458
|
if (!(await pathExists(projectsDir))) {
|
|
454
459
|
return [];
|
|
@@ -463,7 +468,13 @@ export const findCursorTranscriptDirs = async (
|
|
|
463
468
|
}
|
|
464
469
|
|
|
465
470
|
for (const projectDir of projectDirs) {
|
|
466
|
-
const
|
|
471
|
+
const agentTranscriptsDir = path.resolve(projectsDir, projectDir, 'agent-transcripts');
|
|
472
|
+
const transcriptDir = path.resolve(agentTranscriptsDir, composerId);
|
|
473
|
+
const relativePath = path.relative(agentTranscriptsDir, transcriptDir);
|
|
474
|
+
if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
467
478
|
if (await pathExists(transcriptDir)) {
|
|
468
479
|
matches.push(transcriptDir);
|
|
469
480
|
}
|
|
@@ -648,8 +659,8 @@ const readCursorFileHistoryProjectActivity = async (userDir: string): Promise<Ma
|
|
|
648
659
|
|
|
649
660
|
const inferFolderFromBubbles = (db: Database, composerId: string): string | null => {
|
|
650
661
|
const rows = db
|
|
651
|
-
.query(
|
|
652
|
-
.all(
|
|
662
|
+
.query(`SELECT value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\' LIMIT 80`)
|
|
663
|
+
.all(buildCursorBubbleKeyLikePattern(composerId)) as Array<{ value: string }>;
|
|
653
664
|
const paths: string[] = [];
|
|
654
665
|
|
|
655
666
|
for (const { value } of rows) {
|
|
@@ -973,7 +984,7 @@ const buildBucketlessGroup = (key: string, threadCount: number, lastActiveMs: nu
|
|
|
973
984
|
lastActiveMs,
|
|
974
985
|
needsRecovery: false,
|
|
975
986
|
threadCount,
|
|
976
|
-
uri: folder ?
|
|
987
|
+
uri: folder ? toFileUri(folder) : '',
|
|
977
988
|
};
|
|
978
989
|
};
|
|
979
990
|
|
|
@@ -1475,8 +1486,8 @@ export const readCursorThreadTranscriptWithAgentFiles = async (
|
|
|
1475
1486
|
|
|
1476
1487
|
const readAllBubbleIds = (db: Database, composerId: string): string[] => {
|
|
1477
1488
|
const prefix = `bubbleId:${composerId}:`;
|
|
1478
|
-
const rows = db
|
|
1479
|
-
key
|
|
1480
|
-
|
|
1489
|
+
const rows = db
|
|
1490
|
+
.query(`SELECT key FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\' ORDER BY key ASC`)
|
|
1491
|
+
.all(buildCursorBubbleKeyLikePattern(composerId)) as Array<{ key: string }>;
|
|
1481
1492
|
return rows.map((row) => row.key.slice(prefix.length));
|
|
1482
1493
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const SAFE_CURSOR_COMPOSER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
|
|
2
|
+
|
|
3
|
+
export const isSafeCursorComposerId = (value: string): boolean => {
|
|
4
|
+
return SAFE_CURSOR_COMPOSER_ID_PATTERN.test(value) && !value.includes('..');
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const assertSafeCursorComposerId = (value: string): void => {
|
|
8
|
+
if (!isSafeCursorComposerId(value)) {
|
|
9
|
+
throw new Error(`Invalid Cursor composer id: ${value}`);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const escapeSqlLikeValue = (value: string): string => value.replace(/[\\%_]/gu, '\\$&');
|
|
14
|
+
|
|
15
|
+
export const buildCursorBubbleKeyLikePattern = (composerId: string): string => {
|
|
16
|
+
return `bubbleId:${escapeSqlLikeValue(composerId)}:%`;
|
|
17
|
+
};
|