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,7 +1,13 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
2
|
import { rm } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import type { Readable, Writable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import {
|
|
7
|
+
parseCodexTranscriptRecord,
|
|
8
|
+
shouldHideCodexTranscriptText,
|
|
9
|
+
stripCodexMemoryCitationBlocks,
|
|
10
|
+
} from './codex-thread-parser';
|
|
5
11
|
import {
|
|
6
12
|
type CodexTranscriptExportTarget,
|
|
7
13
|
type CodexTranscriptRenderOptions,
|
|
@@ -23,6 +29,7 @@ import {
|
|
|
23
29
|
type JsonValue,
|
|
24
30
|
type MetadataEntry,
|
|
25
31
|
readJsonlObjects,
|
|
32
|
+
renderCodeBlock,
|
|
26
33
|
renderDocumentTitle,
|
|
27
34
|
renderMetadataBlock,
|
|
28
35
|
renderSection,
|
|
@@ -30,6 +37,10 @@ import {
|
|
|
30
37
|
} from './shared';
|
|
31
38
|
import { runWithTranscriptLoadLimit } from './transcript-load-limiter';
|
|
32
39
|
|
|
40
|
+
export const pipeCodexExportStream = async (source: Readable, destination: Writable) => {
|
|
41
|
+
await pipeline(source, destination, { end: false });
|
|
42
|
+
};
|
|
43
|
+
|
|
33
44
|
export const renderCodexSessionFile = async (
|
|
34
45
|
target: CodexTranscriptExportTarget,
|
|
35
46
|
options: CodexTranscriptRenderOptions,
|
|
@@ -122,8 +133,7 @@ export const writeCodexSessionFileExport = async (
|
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
const transcriptReadStream = createReadStream(transcriptOutputPath, { encoding: 'utf8' });
|
|
125
|
-
transcriptReadStream
|
|
126
|
-
await finished(transcriptReadStream);
|
|
136
|
+
await pipeCodexExportStream(transcriptReadStream, outputStream);
|
|
127
137
|
outputStream.write('\n');
|
|
128
138
|
await finalizeExportWriteStream(outputStream);
|
|
129
139
|
} catch (error) {
|
|
@@ -512,45 +522,22 @@ const normalizeMessage = (value: Record<string, JsonValue>): MessageRecord | nul
|
|
|
512
522
|
};
|
|
513
523
|
|
|
514
524
|
const extractToolRecord = (parsed: Record<string, JsonValue>): ToolRecord | null => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const payload = asObject(parsed.payload);
|
|
520
|
-
if (!payload) {
|
|
521
|
-
return null;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (payload.type === 'function_call') {
|
|
525
|
-
const name = asString(payload.name);
|
|
526
|
-
const argumentsText = asString(payload.arguments);
|
|
527
|
-
const callId = asString(payload.call_id);
|
|
528
|
-
|
|
529
|
-
if (name !== 'exec_command') {
|
|
530
|
-
return null;
|
|
531
|
-
}
|
|
532
|
-
|
|
525
|
+
const event = parseCodexTranscriptRecord(parsed);
|
|
526
|
+
if (event?.kind === 'tool_call') {
|
|
533
527
|
return {
|
|
534
|
-
argumentsText: argumentsText ?? undefined,
|
|
535
|
-
callId,
|
|
528
|
+
argumentsText: event.argumentsText ?? undefined,
|
|
529
|
+
callId: event.callId,
|
|
536
530
|
kind: 'call',
|
|
537
|
-
name,
|
|
531
|
+
name: event.name,
|
|
538
532
|
};
|
|
539
533
|
}
|
|
540
534
|
|
|
541
|
-
if (
|
|
542
|
-
const callId = asString(payload.call_id);
|
|
543
|
-
const outputText = asString(payload.output);
|
|
544
|
-
|
|
545
|
-
if (!outputText?.includes('Command: ')) {
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
|
|
535
|
+
if (event?.kind === 'tool_output') {
|
|
549
536
|
return {
|
|
550
|
-
callId,
|
|
537
|
+
callId: event.callId,
|
|
551
538
|
kind: 'output',
|
|
552
|
-
name: '
|
|
553
|
-
outputText: outputText
|
|
539
|
+
name: 'tool_output',
|
|
540
|
+
outputText: event.outputText,
|
|
554
541
|
};
|
|
555
542
|
}
|
|
556
543
|
|
|
@@ -572,8 +559,10 @@ const renderMessageBlock = (
|
|
|
572
559
|
}
|
|
573
560
|
|
|
574
561
|
const extractedText = extractText(message.content);
|
|
575
|
-
const text =
|
|
576
|
-
|
|
562
|
+
const text = stripCodexMemoryCitationBlocks(
|
|
563
|
+
stripCodexAppDirectiveLines(cleanExtractedText(stripPreviewBlock(extractedText))),
|
|
564
|
+
);
|
|
565
|
+
if (!text || shouldHideCodexTranscriptText(message.role, text)) {
|
|
577
566
|
return '';
|
|
578
567
|
}
|
|
579
568
|
|
|
@@ -586,12 +575,18 @@ const renderMessageBlock = (
|
|
|
586
575
|
const renderToolBlock = (tool: ToolRecord, outputFormat: ExportFormat): string => {
|
|
587
576
|
if (tool.kind === 'call') {
|
|
588
577
|
const details = formatToolCallDetails(tool, outputFormat);
|
|
589
|
-
return
|
|
578
|
+
return renderSection('Tool Call', details, outputFormat);
|
|
590
579
|
}
|
|
591
580
|
|
|
592
581
|
const outputText = tool.outputText ?? '';
|
|
593
582
|
const summary = formatToolOutputSummary(outputText, outputFormat);
|
|
594
|
-
|
|
583
|
+
if (!summary && !outputText.trim()) {
|
|
584
|
+
return '';
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const lines = tool.callId ? [`Call ID: ${tool.callId}`, ''] : [];
|
|
588
|
+
lines.push(summary || renderCodeBlock(outputText.trim(), outputFormat));
|
|
589
|
+
return renderSection('Tool Output', lines.join('\n'), outputFormat);
|
|
595
590
|
};
|
|
596
591
|
|
|
597
592
|
const stripPreviewBlock = (text: string): string => {
|
|
@@ -606,7 +601,7 @@ const stripPreviewBlock = (text: string): string => {
|
|
|
606
601
|
|
|
607
602
|
const first = parts[0];
|
|
608
603
|
const second = parts[1];
|
|
609
|
-
const isTranscriptHeading = (value: string) => /^##\s
|
|
604
|
+
const isTranscriptHeading = (value: string) => /^##\s+(?:assistant|user)$/i.test(value);
|
|
610
605
|
const looksLikePreview =
|
|
611
606
|
!/^([UA]):/i.test(first) &&
|
|
612
607
|
!isTranscriptHeading(first) &&
|
|
@@ -620,33 +615,26 @@ const stripPreviewBlock = (text: string): string => {
|
|
|
620
615
|
return parts.slice(1).join('\n\n');
|
|
621
616
|
};
|
|
622
617
|
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (text.startsWith('AGENTS.md instructions for ')) {
|
|
629
|
-
return true;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
if (text.startsWith('# AGENTS.md instructions for ')) {
|
|
633
|
-
return true;
|
|
618
|
+
const formatToolCallDetails = (tool: ToolRecord, outputFormat: ExportFormat): string => {
|
|
619
|
+
const lines = [`Tool: ${formatInlineLiteral(tool.name, outputFormat)}`];
|
|
620
|
+
if (tool.callId) {
|
|
621
|
+
lines.push(`Call ID: ${tool.callId}`);
|
|
634
622
|
}
|
|
635
623
|
|
|
636
|
-
if (
|
|
637
|
-
|
|
624
|
+
if (tool.name === 'exec_command') {
|
|
625
|
+
const details = parseExecCommandArguments(tool.argumentsText);
|
|
626
|
+
if (details.cmd) {
|
|
627
|
+
lines.push(`Command: ${formatInlineLiteral(details.cmd, outputFormat)}`);
|
|
628
|
+
return lines.join('\n');
|
|
629
|
+
}
|
|
638
630
|
}
|
|
639
631
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const formatToolCallDetails = (tool: ToolRecord, outputFormat: ExportFormat): string => {
|
|
644
|
-
if (tool.name !== 'exec_command') {
|
|
645
|
-
return '';
|
|
632
|
+
const argumentsText = tool.argumentsText?.trim();
|
|
633
|
+
if (argumentsText) {
|
|
634
|
+
lines.push('', 'Input:', '', renderCodeBlock(argumentsText, outputFormat));
|
|
646
635
|
}
|
|
647
636
|
|
|
648
|
-
|
|
649
|
-
return details.cmd ? `Command: ${formatInlineLiteral(details.cmd, outputFormat)}` : '';
|
|
637
|
+
return lines.join('\n');
|
|
650
638
|
};
|
|
651
639
|
|
|
652
640
|
const extractText = (content: JsonValue): string => {
|
package/src/lib/concurrency.ts
CHANGED
|
@@ -11,16 +11,28 @@ export const mapWithConcurrency = async <T, TResult>(
|
|
|
11
11
|
const workerLimit = Math.max(1, requestedLimit);
|
|
12
12
|
const results = new Array<TResult>(values.length);
|
|
13
13
|
let nextIndex = 0;
|
|
14
|
+
let failed = false;
|
|
14
15
|
|
|
15
16
|
const worker = async () => {
|
|
16
|
-
while (nextIndex < values.length) {
|
|
17
|
+
while (!failed && nextIndex < values.length) {
|
|
17
18
|
const currentIndex = nextIndex;
|
|
18
19
|
nextIndex += 1;
|
|
19
|
-
|
|
20
|
+
try {
|
|
21
|
+
results[currentIndex] = await mapper(values[currentIndex]!, currentIndex);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
failed = true;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
20
26
|
}
|
|
21
27
|
};
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
const settledWorkers = await Promise.allSettled(
|
|
30
|
+
Array.from({ length: Math.min(workerLimit, values.length) }, () => worker()),
|
|
31
|
+
);
|
|
32
|
+
const failure = settledWorkers.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
33
|
+
if (failure) {
|
|
34
|
+
throw failure.reason;
|
|
35
|
+
}
|
|
24
36
|
return results;
|
|
25
37
|
};
|
|
26
38
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { mapWithConcurrency } from './concurrency';
|
|
2
3
|
import {
|
|
3
4
|
type ConversationDetail,
|
|
@@ -98,10 +99,14 @@ const parseSources = (value: string | null): ParseResult<ConversationSource[] |
|
|
|
98
99
|
return { value: 'all' };
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
const sources =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const sources = [
|
|
103
|
+
...new Set(
|
|
104
|
+
value
|
|
105
|
+
.split(',')
|
|
106
|
+
.map((source) => source.trim())
|
|
107
|
+
.filter(Boolean),
|
|
108
|
+
),
|
|
109
|
+
];
|
|
105
110
|
const invalidSource = sources.find((source) => !isConversationSource(source));
|
|
106
111
|
|
|
107
112
|
return invalidSource ? { error: invalidSourceResponse(invalidSource) } : { value: sources as ConversationSource[] };
|
|
@@ -169,6 +174,9 @@ const validatePathLength = (field: string, value: string): Response | null => {
|
|
|
169
174
|
: invalidFieldResponse(field, value.length, `\`${field}\` is too long.`);
|
|
170
175
|
};
|
|
171
176
|
|
|
177
|
+
const validateAbsoluteCwd = (cwd: string): Response | null =>
|
|
178
|
+
path.isAbsolute(cwd) ? null : invalidFieldResponse('cwd', cwd, '`cwd` must be an absolute path.');
|
|
179
|
+
|
|
172
180
|
const validateTimestamp = (field: string, value: number | undefined): Response | null => {
|
|
173
181
|
if (value === undefined) {
|
|
174
182
|
return null;
|
|
@@ -179,14 +187,10 @@ const validateTimestamp = (field: string, value: number | undefined): Response |
|
|
|
179
187
|
: invalidFieldResponse(field, value, `\`${field}\` must be a non-negative epoch millisecond timestamp.`);
|
|
180
188
|
};
|
|
181
189
|
|
|
182
|
-
const
|
|
190
|
+
const validateConversationId = (id: string): Response | null => {
|
|
183
191
|
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u.test(id) && !id.includes('..')
|
|
184
192
|
? null
|
|
185
|
-
: invalidFieldResponse(
|
|
186
|
-
'id',
|
|
187
|
-
id,
|
|
188
|
-
'Conversation id contains characters that are not allowed for destructive requests.',
|
|
189
|
-
);
|
|
193
|
+
: invalidFieldResponse('id', id, 'Conversation id contains characters that are not allowed by the stable API.');
|
|
190
194
|
};
|
|
191
195
|
|
|
192
196
|
const parseListLimitParam = (value: string | null): ParseResult<number | undefined> => {
|
|
@@ -232,6 +236,10 @@ const buildListOptions = (url: URL): ParseResult<ListConversationsForPathOptions
|
|
|
232
236
|
if (cwdLengthError) {
|
|
233
237
|
return { error: cwdLengthError };
|
|
234
238
|
}
|
|
239
|
+
const cwdAbsoluteError = validateAbsoluteCwd(cwd);
|
|
240
|
+
if (cwdAbsoluteError) {
|
|
241
|
+
return { error: cwdAbsoluteError };
|
|
242
|
+
}
|
|
235
243
|
|
|
236
244
|
const cursor = url.searchParams.get('cursor');
|
|
237
245
|
const cursorError = validateCursor(cursor);
|
|
@@ -279,7 +287,7 @@ const buildListOptions = (url: URL): ParseResult<ListConversationsForPathOptions
|
|
|
279
287
|
};
|
|
280
288
|
|
|
281
289
|
const normalizeMeta = (meta: { hasNext: boolean; nextCursor: string | null }) => ({
|
|
282
|
-
|
|
290
|
+
has_next: meta.hasNext,
|
|
283
291
|
next_cursor: meta.nextCursor,
|
|
284
292
|
});
|
|
285
293
|
|
|
@@ -370,9 +378,9 @@ const buildDeleteConversationOptions = (
|
|
|
370
378
|
if (!decodedId.trim() || decodedId.length > MAX_ID_LENGTH) {
|
|
371
379
|
return { error: invalidFieldResponse('id', decodedId.length, 'Conversation id is invalid.') };
|
|
372
380
|
}
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
return { error:
|
|
381
|
+
const idError = validateConversationId(decodedId);
|
|
382
|
+
if (idError) {
|
|
383
|
+
return { error: idError };
|
|
376
384
|
}
|
|
377
385
|
|
|
378
386
|
return {
|
|
@@ -497,10 +505,7 @@ const parseJsonSourceOption = (body: Record<string, unknown>): ParseResult<Conve
|
|
|
497
505
|
return isConversationSource(source) ? { value: source } : { error: invalidSourceResponse(source) };
|
|
498
506
|
};
|
|
499
507
|
|
|
500
|
-
const parseJsonIdsOption = (
|
|
501
|
-
body: Record<string, unknown>,
|
|
502
|
-
options: { destructive: boolean },
|
|
503
|
-
): ParseResult<string[]> => {
|
|
508
|
+
const parseJsonIdsOption = (body: Record<string, unknown>): ParseResult<string[]> => {
|
|
504
509
|
const idsValue = getOption(body, 'ids', 'ids');
|
|
505
510
|
if (!Array.isArray(idsValue) || idsValue.length === 0) {
|
|
506
511
|
return {
|
|
@@ -534,9 +539,9 @@ const parseJsonIdsOption = (
|
|
|
534
539
|
};
|
|
535
540
|
}
|
|
536
541
|
|
|
537
|
-
const
|
|
538
|
-
if (
|
|
539
|
-
return { error:
|
|
542
|
+
const idError = validateConversationId(id);
|
|
543
|
+
if (idError) {
|
|
544
|
+
return { error: idError };
|
|
540
545
|
}
|
|
541
546
|
|
|
542
547
|
if (!seenIds.has(id)) {
|
|
@@ -570,16 +575,13 @@ const parseJsonExportMessageSelector = (body: Record<string, unknown>): ParseRes
|
|
|
570
575
|
return parseMessageSelector(messageSelectorValue.value ?? null, 'all');
|
|
571
576
|
};
|
|
572
577
|
|
|
573
|
-
const parseConversationIdSetRecord = (
|
|
574
|
-
body: Record<string, unknown>,
|
|
575
|
-
options: { destructive: boolean },
|
|
576
|
-
): ParseResult<DeleteConversationsOptions> => {
|
|
578
|
+
const parseConversationIdSetRecord = (body: Record<string, unknown>): ParseResult<DeleteConversationsOptions> => {
|
|
577
579
|
const source = parseJsonSourceOption(body);
|
|
578
580
|
if ('error' in source) {
|
|
579
581
|
return source;
|
|
580
582
|
}
|
|
581
583
|
|
|
582
|
-
const ids = parseJsonIdsOption(body
|
|
584
|
+
const ids = parseJsonIdsOption(body);
|
|
583
585
|
if ('error' in ids) {
|
|
584
586
|
return ids;
|
|
585
587
|
}
|
|
@@ -598,7 +600,7 @@ const parseExportConversationsBody = async (request: Request): Promise<ParseResu
|
|
|
598
600
|
return body;
|
|
599
601
|
}
|
|
600
602
|
|
|
601
|
-
const idSet = parseConversationIdSetRecord(body.value
|
|
603
|
+
const idSet = parseConversationIdSetRecord(body.value);
|
|
602
604
|
if ('error' in idSet) {
|
|
603
605
|
return idSet;
|
|
604
606
|
}
|
|
@@ -629,7 +631,7 @@ const handleDeleteConversations = async (request: Request, dependencies: ReturnT
|
|
|
629
631
|
return body.error;
|
|
630
632
|
}
|
|
631
633
|
|
|
632
|
-
const result = parseConversationIdSetRecord(body.value
|
|
634
|
+
const result = parseConversationIdSetRecord(body.value);
|
|
633
635
|
if ('error' in result) {
|
|
634
636
|
return result.error;
|
|
635
637
|
}
|
|
@@ -845,8 +847,8 @@ const parseJsonCwd = (body: Record<string, unknown>): ParseResult<string> => {
|
|
|
845
847
|
return { error: errorResponse('validation_error', '`cwd` is required.', 400, { field: 'cwd' }) };
|
|
846
848
|
}
|
|
847
849
|
|
|
848
|
-
const
|
|
849
|
-
return
|
|
850
|
+
const cwdError = validatePathLength('cwd', cwd) ?? validateAbsoluteCwd(cwd);
|
|
851
|
+
return cwdError ? { error: cwdError } : { value: cwd };
|
|
850
852
|
};
|
|
851
853
|
|
|
852
854
|
const parseJsonCursor = (body: Record<string, unknown>): ParseResult<string | null> => {
|
|
@@ -954,6 +956,7 @@ const validateListQueryOptions = (options: ListConversationsForPathOptions): Res
|
|
|
954
956
|
|
|
955
957
|
return (
|
|
956
958
|
validatePathLength('cwd', options.cwd) ??
|
|
959
|
+
validateAbsoluteCwd(options.cwd) ??
|
|
957
960
|
validateCursor(options.cursor) ??
|
|
958
961
|
validateLimit(options.limit) ??
|
|
959
962
|
validateTimestamp('updated_after_ms', options.updatedAfterMs) ??
|
|
@@ -1017,7 +1020,7 @@ const API_ROUTES: ApiRoute[] = [
|
|
|
1017
1020
|
},
|
|
1018
1021
|
{
|
|
1019
1022
|
handle: ({ dependencies, url }) => handleListConversations(url, dependencies),
|
|
1020
|
-
matches: ({ source }) => !source,
|
|
1023
|
+
matches: ({ action, source }) => !source && !action,
|
|
1021
1024
|
method: 'GET',
|
|
1022
1025
|
resource: 'conversations',
|
|
1023
1026
|
},
|
|
@@ -4,8 +4,23 @@ import type {
|
|
|
4
4
|
ConversationMessagePhase,
|
|
5
5
|
ConversationMessageRole,
|
|
6
6
|
ConversationSource,
|
|
7
|
+
ListConversationsForPathOptions,
|
|
7
8
|
} from './types';
|
|
8
9
|
|
|
10
|
+
export const isWithinUpdatedWindow = (
|
|
11
|
+
updatedAtMs: number | null | undefined,
|
|
12
|
+
options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
|
|
13
|
+
) => {
|
|
14
|
+
const comparableUpdatedAtMs = updatedAtMs ?? 0;
|
|
15
|
+
if (options.updatedAfterMs !== undefined && comparableUpdatedAtMs < options.updatedAfterMs) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (options.updatedBeforeMs !== undefined && comparableUpdatedAtMs > options.updatedBeforeMs) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
|
|
9
24
|
export const toDateMs = (value: string | number | null | undefined): number | null => {
|
|
10
25
|
if (typeof value === 'number') {
|
|
11
26
|
return Number.isFinite(value) ? value : null;
|
|
@@ -36,8 +51,12 @@ export const decodeFileUri = (value: string | null | undefined): string | null =
|
|
|
36
51
|
}
|
|
37
52
|
return pathname.replace(/^\/([A-Za-z]:)/u, '$1');
|
|
38
53
|
} catch {
|
|
39
|
-
const
|
|
40
|
-
|
|
54
|
+
const rawPathValue = value.slice('file://'.length);
|
|
55
|
+
try {
|
|
56
|
+
return decodeURIComponent(rawPathValue).replace(/^\/([A-Za-z]:)/u, '$1');
|
|
57
|
+
} catch {
|
|
58
|
+
return rawPathValue.replace(/^\/([A-Za-z]:)/u, '$1');
|
|
59
|
+
}
|
|
41
60
|
}
|
|
42
61
|
};
|
|
43
62
|
|
|
@@ -48,10 +67,13 @@ export const createDeepLinks = (
|
|
|
48
67
|
native: string | null = null,
|
|
49
68
|
): ConversationDeepLinks => ({
|
|
50
69
|
native,
|
|
51
|
-
spiracha: `spiracha://conversation/${source}/${id}`,
|
|
70
|
+
spiracha: `spiracha://conversation/${source}/${encodeURIComponent(id)}`,
|
|
52
71
|
ui: uiPath,
|
|
53
72
|
});
|
|
54
73
|
|
|
74
|
+
export const createConversationUiPath = (routeSegment: string, id: string) =>
|
|
75
|
+
`/${routeSegment}/${encodeURIComponent(id)}`;
|
|
76
|
+
|
|
55
77
|
export const normalizeRole = (role: string | null | undefined): ConversationMessageRole => {
|
|
56
78
|
if (role === 'assistant' || role === 'system' || role === 'tool' || role === 'user') {
|
|
57
79
|
return role;
|
|
@@ -5,9 +5,17 @@ import {
|
|
|
5
5
|
} from '../antigravity-db';
|
|
6
6
|
import type { AntigravityConversation } from '../antigravity-exporter-types';
|
|
7
7
|
import { resolveAntigravityRoots } from '../antigravity-exporter-types';
|
|
8
|
+
import { mapWithConcurrency } from '../concurrency';
|
|
8
9
|
import { cleanInlineTitle } from '../shared';
|
|
9
10
|
import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
|
|
10
|
-
import {
|
|
11
|
+
import { getFileFingerprint, hashCacheKeyPartsIterable, withCachedJson } from '../ui-cache';
|
|
12
|
+
import {
|
|
13
|
+
createConversationUiPath,
|
|
14
|
+
createDeepLinks,
|
|
15
|
+
decodeFileUri,
|
|
16
|
+
finalizeMessages,
|
|
17
|
+
isWithinUpdatedWindow,
|
|
18
|
+
} from './adapter-helpers';
|
|
11
19
|
import { selectConversationMessages } from './message-selector';
|
|
12
20
|
import { getConversationPathMatch, getFirstConversationPathMatch } from './path-match';
|
|
13
21
|
import type {
|
|
@@ -27,22 +35,33 @@ const getWorkspacePath = (conversation: AntigravityConversation) =>
|
|
|
27
35
|
conversation.workspaceFolder ?? decodeFileUri(conversation.workspaceUri);
|
|
28
36
|
|
|
29
37
|
const stripTrailingPathPunctuation = (value: string) => value.replace(/[),.;:\]`]+$/u, '');
|
|
38
|
+
const PATH_REFERENCE_FALLBACK_LIMIT = 100;
|
|
39
|
+
const PATH_REFERENCE_CONCURRENCY = 4;
|
|
30
40
|
|
|
31
41
|
const extractAbsolutePathReferences = (text: string): string[] => {
|
|
32
42
|
return [...new Set((text.match(/\/[^\s"'`)\]]+/gu) ?? []).map(stripTrailingPathPunctuation))];
|
|
33
43
|
};
|
|
34
44
|
|
|
35
45
|
const readMessages = async (conversation: AntigravityConversation) => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
const load = () =>
|
|
47
|
+
runWithTranscriptLoadLimit(() => readAntigravityConversationMessages(conversation), {
|
|
48
|
+
id: conversation.conversationId,
|
|
49
|
+
path: conversation.transcriptPath ?? conversation.conversationPath ?? undefined,
|
|
50
|
+
source: 'antigravity-api',
|
|
51
|
+
});
|
|
52
|
+
const transcriptPath = conversation.transcriptPath;
|
|
53
|
+
const messages = transcriptPath
|
|
54
|
+
? await getFileFingerprint(transcriptPath)
|
|
55
|
+
.then((fingerprint) =>
|
|
56
|
+
withCachedJson(`antigravity-api-messages-${hashCacheKeyPartsIterable([fingerprint])}`, load),
|
|
57
|
+
)
|
|
58
|
+
.catch(load)
|
|
59
|
+
: await load();
|
|
41
60
|
return finalizeMessages(
|
|
42
61
|
messages.map(
|
|
43
|
-
(message): ConversationMessage => ({
|
|
62
|
+
(message, entryIndex): ConversationMessage => ({
|
|
44
63
|
...message,
|
|
45
|
-
id: `${conversation.conversationId}:${message.order}:${message.role}:${message.phase}`,
|
|
64
|
+
id: `${conversation.conversationId}:${message.order}:${message.role}:${message.phase}:${entryIndex}`,
|
|
46
65
|
metadata: {
|
|
47
66
|
...message.metadata,
|
|
48
67
|
model: conversation.model,
|
|
@@ -70,7 +89,7 @@ const buildConversation = async (
|
|
|
70
89
|
deepLinks: createDeepLinks(
|
|
71
90
|
'antigravity',
|
|
72
91
|
conversation.conversationId,
|
|
73
|
-
|
|
92
|
+
createConversationUiPath('antigravity-conversations', conversation.conversationId),
|
|
74
93
|
),
|
|
75
94
|
id: conversation.conversationId,
|
|
76
95
|
matches,
|
|
@@ -90,20 +109,6 @@ const buildConversation = async (
|
|
|
90
109
|
};
|
|
91
110
|
};
|
|
92
111
|
|
|
93
|
-
const isWithinUpdatedWindow = (
|
|
94
|
-
conversation: AntigravityConversation,
|
|
95
|
-
options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
|
|
96
|
-
) => {
|
|
97
|
-
const updatedAtMs = conversation.lastUpdatedAtMs ?? conversation.conversationMtimeMs ?? 0;
|
|
98
|
-
if (options.updatedAfterMs !== undefined && updatedAtMs < options.updatedAfterMs) {
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
if (options.updatedBeforeMs !== undefined && updatedAtMs > options.updatedBeforeMs) {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
return true;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
112
|
const getReferencedPathMatch = async (
|
|
108
113
|
requestedPath: string,
|
|
109
114
|
messages: ConversationMessage[],
|
|
@@ -116,9 +121,10 @@ const listAntigravityConversationsForPath = async (options: ListConversationsFor
|
|
|
116
121
|
const roots = getRoots(options);
|
|
117
122
|
const conversations = await listAntigravityConversations(roots);
|
|
118
123
|
const result: ConversationDetail[] = [];
|
|
124
|
+
const pathReferenceCandidates: AntigravityConversation[] = [];
|
|
119
125
|
|
|
120
126
|
for (const conversation of conversations) {
|
|
121
|
-
if (!isWithinUpdatedWindow(conversation, options)) {
|
|
127
|
+
if (!isWithinUpdatedWindow(conversation.lastUpdatedAtMs ?? conversation.conversationMtimeMs, options)) {
|
|
122
128
|
continue;
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -129,13 +135,32 @@ const listAntigravityConversationsForPath = async (options: ListConversationsFor
|
|
|
129
135
|
continue;
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
|
|
133
|
-
const referencedPathMatch = await getReferencedPathMatch(options.cwd, messages);
|
|
134
|
-
if (referencedPathMatch) {
|
|
135
|
-
result.push(await buildConversation(conversation, [referencedPathMatch], options, messages));
|
|
136
|
-
}
|
|
138
|
+
pathReferenceCandidates.push(conversation);
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
const referencedConversations = await mapWithConcurrency(
|
|
142
|
+
pathReferenceCandidates.slice(0, PATH_REFERENCE_FALLBACK_LIMIT),
|
|
143
|
+
PATH_REFERENCE_CONCURRENCY,
|
|
144
|
+
async (conversation) => {
|
|
145
|
+
let messages: ConversationMessage[];
|
|
146
|
+
try {
|
|
147
|
+
messages = await readMessages(conversation);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.warn('[spiracha:antigravity] skipped unreadable path-reference transcript', {
|
|
150
|
+
conversationId: conversation.conversationId,
|
|
151
|
+
error: error instanceof Error ? error.message : String(error),
|
|
152
|
+
});
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const referencedPathMatch = await getReferencedPathMatch(options.cwd, messages);
|
|
156
|
+
if (referencedPathMatch) {
|
|
157
|
+
return buildConversation(conversation, [referencedPathMatch], options, messages);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
result.push(...referencedConversations.flatMap((conversation) => (conversation ? [conversation] : [])));
|
|
163
|
+
|
|
139
164
|
return result;
|
|
140
165
|
};
|
|
141
166
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
deleteClaudeCodeSession,
|
|
3
|
-
|
|
3
|
+
listClaudeCodeSessionTranscriptsForGroup,
|
|
4
4
|
listClaudeCodeWorkspaceGroups,
|
|
5
5
|
readClaudeCodeSessionTranscript,
|
|
6
6
|
} from '../claude-code-db';
|
|
@@ -11,12 +11,15 @@ import type {
|
|
|
11
11
|
ClaudeCodeTranscriptPart,
|
|
12
12
|
} from '../claude-code-exporter-types';
|
|
13
13
|
import { getClaudeCodeAssistantMessagePhase, resolveClaudeCodeProjectsDir } from '../claude-code-exporter-types';
|
|
14
|
+
import { mapWithConcurrency } from '../concurrency';
|
|
14
15
|
import { cleanInlineTitle } from '../shared';
|
|
15
16
|
import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
|
|
16
17
|
import {
|
|
18
|
+
createConversationUiPath,
|
|
17
19
|
createDeepLinks,
|
|
18
20
|
createTextMessage,
|
|
19
21
|
finalizeMessages,
|
|
22
|
+
isWithinUpdatedWindow,
|
|
20
23
|
normalizeAssistantPhase,
|
|
21
24
|
normalizeRole,
|
|
22
25
|
toDateMs,
|
|
@@ -33,6 +36,8 @@ import type {
|
|
|
33
36
|
ListConversationsForPathOptions,
|
|
34
37
|
} from './types';
|
|
35
38
|
|
|
39
|
+
const CLAUDE_CONVERSATION_HYDRATION_CONCURRENCY = 4;
|
|
40
|
+
|
|
36
41
|
const getProjectsDir = (options: { locations?: { claudeCodeProjectsDir?: string } }) =>
|
|
37
42
|
options.locations?.claudeCodeProjectsDir ?? resolveClaudeCodeProjectsDir();
|
|
38
43
|
|
|
@@ -89,6 +94,19 @@ const partToMessages = (
|
|
|
89
94
|
});
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
if (part.type === 'attachment') {
|
|
98
|
+
const attachmentLabel = part.attachmentType?.trim() || 'file';
|
|
99
|
+
return createTextMessage({
|
|
100
|
+
createdAtMs,
|
|
101
|
+
id: baseId,
|
|
102
|
+
metadata: { attachmentType: part.attachmentType ?? null },
|
|
103
|
+
order: partIndex,
|
|
104
|
+
phase: 'unknown',
|
|
105
|
+
role: normalizeRole(entry.role),
|
|
106
|
+
text: part.text?.trim() || `[Attachment: ${attachmentLabel}]`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
return [];
|
|
93
111
|
};
|
|
94
112
|
|
|
@@ -122,7 +140,11 @@ const buildConversation = async (
|
|
|
122
140
|
|
|
123
141
|
return {
|
|
124
142
|
createdAtMs: session.createdAtMs,
|
|
125
|
-
deepLinks: createDeepLinks(
|
|
143
|
+
deepLinks: createDeepLinks(
|
|
144
|
+
'claude-code',
|
|
145
|
+
session.sessionId,
|
|
146
|
+
createConversationUiPath('claude-code-sessions', session.sessionId),
|
|
147
|
+
),
|
|
126
148
|
id: session.sessionId,
|
|
127
149
|
matches,
|
|
128
150
|
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
|
|
@@ -155,10 +177,14 @@ const listClaudeConversationsForPath = async (
|
|
|
155
177
|
continue;
|
|
156
178
|
}
|
|
157
179
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
180
|
+
const transcripts = (await listClaudeCodeSessionTranscriptsForGroup(group.key, projectsDir)).filter(
|
|
181
|
+
(transcript) => isWithinUpdatedWindow(transcript.session.lastActiveAtMs, options),
|
|
182
|
+
);
|
|
183
|
+
conversations.push(
|
|
184
|
+
...(await mapWithConcurrency(transcripts, CLAUDE_CONVERSATION_HYDRATION_CONCURRENCY, (transcript) =>
|
|
185
|
+
buildConversation(transcript.session, projectsDir, [match], options, transcript),
|
|
186
|
+
)),
|
|
187
|
+
);
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
return conversations;
|