spiracha 2.0.0 → 2.1.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/README.md +6 -1
- package/apps/ui/README.md +20 -2
- package/apps/ui/package.json +2 -2
- package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +1 -0
- 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 +7 -5
- package/apps/ui/src/components/export-dialog.tsx +2 -12
- 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/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/threads-table.tsx +9 -46
- 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 +152 -7
- package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +175 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +19 -3
- package/apps/ui/src/lib/codex-server.ts +135 -53
- package/apps/ui/src/lib/cursor-server.ts +48 -5
- 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 +169 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +85 -7
- package/apps/ui/src/lib/opencode-server.ts +85 -7
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
- package/apps/ui/src/routeTree.gen.ts +107 -0
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
- 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 +143 -17
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
- package/apps/ui/src/routes/opencode.index.tsx +16 -3
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
- package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
- package/package.json +19 -18
- package/src/client.ts +134 -1
- package/src/lib/antigravity-db.ts +208 -32
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/claude-code-db.ts +498 -32
- 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-browser-export.ts +23 -27
- package/src/lib/codex-thread-cache.ts +41 -13
- package/src/lib/codex-thread-parser.ts +86 -35
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +39 -19
- package/src/lib/concurrency.ts +41 -0
- package/src/lib/conversation-api.ts +399 -19
- package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
- package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
- package/src/lib/conversation-data/codex-adapter.ts +28 -4
- package/src/lib/conversation-data/cursor-adapter.ts +35 -1
- package/src/lib/conversation-data/grok-adapter.ts +210 -0
- package/src/lib/conversation-data/index.ts +76 -1
- package/src/lib/conversation-data/kiro-adapter.ts +41 -7
- package/src/lib/conversation-data/opencode-adapter.ts +24 -2
- package/src/lib/conversation-data/qoder-adapter.ts +44 -19
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +57 -0
- package/src/lib/cursor-db.ts +34 -5
- package/src/lib/grok-db.ts +1026 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +52 -0
- package/src/lib/grok-transcript.ts +154 -0
- package/src/lib/kiro-db.ts +52 -1
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +598 -55
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/shared.ts +12 -0
- package/src/lib/transcript-load-limiter.ts +82 -0
- package/src/lib/ui-cache.ts +43 -17
- package/src/lib/ui-export-archive.ts +60 -0
package/src/lib/concurrency.ts
CHANGED
|
@@ -23,3 +23,44 @@ export const mapWithConcurrency = async <T, TResult>(
|
|
|
23
23
|
await Promise.all(Array.from({ length: Math.min(workerLimit, values.length) }, () => worker()));
|
|
24
24
|
return results;
|
|
25
25
|
};
|
|
26
|
+
|
|
27
|
+
export const createConcurrencyLimiter = (limit: number) => {
|
|
28
|
+
const requestedLimit = Number.isFinite(limit) ? Math.floor(limit) : 1;
|
|
29
|
+
const workerLimit = Math.max(1, requestedLimit);
|
|
30
|
+
const queue: Array<() => void> = [];
|
|
31
|
+
let activeCount = 0;
|
|
32
|
+
|
|
33
|
+
const drain = () => {
|
|
34
|
+
if (activeCount >= workerLimit) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const next = queue.shift();
|
|
39
|
+
if (next) {
|
|
40
|
+
next();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return async <T>(task: () => Promise<T>): Promise<T> => {
|
|
45
|
+
await new Promise<void>((resolve) => {
|
|
46
|
+
const start = () => {
|
|
47
|
+
activeCount += 1;
|
|
48
|
+
resolve();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (activeCount < workerLimit) {
|
|
52
|
+
start();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
queue.push(start);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return await task();
|
|
61
|
+
} finally {
|
|
62
|
+
activeCount -= 1;
|
|
63
|
+
drain();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { mapWithConcurrency } from './concurrency';
|
|
1
2
|
import {
|
|
3
|
+
type ConversationDetail,
|
|
2
4
|
type ConversationMessageSelector,
|
|
3
5
|
type ConversationSource,
|
|
6
|
+
type DeleteConversationOptions,
|
|
7
|
+
type DeleteConversationsOptions,
|
|
8
|
+
deleteConversation,
|
|
9
|
+
deleteConversations,
|
|
10
|
+
type ExportConversationsZipOptions,
|
|
4
11
|
type GetConversationOptions,
|
|
5
12
|
getConversation,
|
|
6
13
|
isConversationSource,
|
|
@@ -10,8 +17,11 @@ import {
|
|
|
10
17
|
renderConversationMarkdown,
|
|
11
18
|
resolveConversationRef,
|
|
12
19
|
} from './conversation-data';
|
|
20
|
+
import { createConversationMarkdownZip } from './conversation-zip-export';
|
|
13
21
|
|
|
14
22
|
type ConversationApiDependencies = {
|
|
23
|
+
deleteConversation?: typeof deleteConversation;
|
|
24
|
+
deleteConversations?: typeof deleteConversations;
|
|
15
25
|
getConversation?: typeof getConversation;
|
|
16
26
|
listConversationSources?: typeof listConversationSources;
|
|
17
27
|
listConversationsForPath?: typeof listConversationsForPath;
|
|
@@ -19,10 +29,18 @@ type ConversationApiDependencies = {
|
|
|
19
29
|
resolveConversationRef?: typeof resolveConversationRef;
|
|
20
30
|
};
|
|
21
31
|
|
|
22
|
-
type ApiErrorCode =
|
|
32
|
+
type ApiErrorCode =
|
|
33
|
+
| 'conversation_not_found'
|
|
34
|
+
| 'internal_error'
|
|
35
|
+
| 'method_not_allowed'
|
|
36
|
+
| 'not_found'
|
|
37
|
+
| 'unsupported_operation'
|
|
38
|
+
| 'validation_error';
|
|
23
39
|
type ParseResult<T> = { error: Response } | { value: T };
|
|
24
40
|
|
|
25
41
|
const MAX_CURSOR_OFFSET = 1_000_000;
|
|
42
|
+
const BATCH_LOAD_CONCURRENCY = 4;
|
|
43
|
+
const MAX_ID_BATCH_SIZE = 200;
|
|
26
44
|
const MAX_ID_LENGTH = 2048;
|
|
27
45
|
const MAX_LIMIT = 200;
|
|
28
46
|
const MAX_PATH_LENGTH = 4096;
|
|
@@ -161,6 +179,16 @@ const validateTimestamp = (field: string, value: number | undefined): Response |
|
|
|
161
179
|
: invalidFieldResponse(field, value, `\`${field}\` must be a non-negative epoch millisecond timestamp.`);
|
|
162
180
|
};
|
|
163
181
|
|
|
182
|
+
const validateDeleteId = (id: string): Response | null => {
|
|
183
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u.test(id) && !id.includes('..')
|
|
184
|
+
? null
|
|
185
|
+
: invalidFieldResponse(
|
|
186
|
+
'id',
|
|
187
|
+
id,
|
|
188
|
+
'Conversation id contains characters that are not allowed for destructive requests.',
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
164
192
|
const parseListLimitParam = (value: string | null): ParseResult<number | undefined> => {
|
|
165
193
|
if (!value) {
|
|
166
194
|
return { value: undefined };
|
|
@@ -256,6 +284,8 @@ const normalizeMeta = (meta: { hasNext: boolean; nextCursor: string | null }) =>
|
|
|
256
284
|
});
|
|
257
285
|
|
|
258
286
|
const getDeps = (dependencies: ConversationApiDependencies) => ({
|
|
287
|
+
deleteConversation: dependencies.deleteConversation ?? deleteConversation,
|
|
288
|
+
deleteConversations: dependencies.deleteConversations ?? deleteConversations,
|
|
259
289
|
getConversation: dependencies.getConversation ?? getConversation,
|
|
260
290
|
listConversationSources: dependencies.listConversationSources ?? listConversationSources,
|
|
261
291
|
listConversationsForPath: dependencies.listConversationsForPath ?? listConversationsForPath,
|
|
@@ -319,6 +349,40 @@ const buildGetConversationOptions = (
|
|
|
319
349
|
};
|
|
320
350
|
};
|
|
321
351
|
|
|
352
|
+
const buildDeleteConversationOptions = (
|
|
353
|
+
source: string | undefined,
|
|
354
|
+
id: string | undefined,
|
|
355
|
+
): ParseResult<DeleteConversationOptions> => {
|
|
356
|
+
if (!source || !id) {
|
|
357
|
+
return { error: errorResponse('validation_error', 'Conversation source and id are required.', 400) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!isConversationSource(source)) {
|
|
361
|
+
return { error: invalidSourceResponse(source) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let decodedId: string;
|
|
365
|
+
try {
|
|
366
|
+
decodedId = decodeURIComponent(id);
|
|
367
|
+
} catch {
|
|
368
|
+
return { error: invalidFieldResponse('id', id, 'Conversation id must be URL encoded.') };
|
|
369
|
+
}
|
|
370
|
+
if (!decodedId.trim() || decodedId.length > MAX_ID_LENGTH) {
|
|
371
|
+
return { error: invalidFieldResponse('id', decodedId.length, 'Conversation id is invalid.') };
|
|
372
|
+
}
|
|
373
|
+
const deleteIdError = validateDeleteId(decodedId);
|
|
374
|
+
if (deleteIdError) {
|
|
375
|
+
return { error: deleteIdError };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
value: {
|
|
380
|
+
id: decodedId,
|
|
381
|
+
source,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
|
|
322
386
|
const handleGetConversation = async (
|
|
323
387
|
source: string | undefined,
|
|
324
388
|
id: string | undefined,
|
|
@@ -360,10 +424,294 @@ const handleExportConversation = async (
|
|
|
360
424
|
});
|
|
361
425
|
}
|
|
362
426
|
|
|
363
|
-
return new Response(
|
|
427
|
+
return new Response(
|
|
428
|
+
dependencies.renderConversationMarkdown(conversation, {
|
|
429
|
+
messageSelector: result.value.messageSelector,
|
|
430
|
+
}),
|
|
431
|
+
{
|
|
432
|
+
headers: {
|
|
433
|
+
'Cache-Control': 'no-store',
|
|
434
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
435
|
+
'X-Content-Type-Options': 'nosniff',
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const handleDeleteConversation = async (
|
|
442
|
+
source: string | undefined,
|
|
443
|
+
id: string | undefined,
|
|
444
|
+
dependencies: ReturnType<typeof getDeps>,
|
|
445
|
+
) => {
|
|
446
|
+
const result = buildDeleteConversationOptions(source, id);
|
|
447
|
+
if ('error' in result) {
|
|
448
|
+
return result.error;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const deleteResult = await dependencies.deleteConversation(result.value);
|
|
452
|
+
if (!deleteResult) {
|
|
453
|
+
return errorResponse(
|
|
454
|
+
'unsupported_operation',
|
|
455
|
+
`Deleting ${result.value.source} conversations is not supported by the stable API.`,
|
|
456
|
+
405,
|
|
457
|
+
{
|
|
458
|
+
source: result.value.source,
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (deleteResult.deletedIds.length === 0) {
|
|
464
|
+
return errorResponse('conversation_not_found', 'No conversation exists for that source and id.', 404, {
|
|
465
|
+
id: result.value.id,
|
|
466
|
+
source: result.value.source,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return jsonResponse({ data: deleteResult });
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const parseJsonBody = async (request: Request): Promise<ParseResult<Record<string, unknown>>> => {
|
|
474
|
+
let body: unknown;
|
|
475
|
+
try {
|
|
476
|
+
body = await request.json();
|
|
477
|
+
} catch {
|
|
478
|
+
return { error: errorResponse('validation_error', 'Request body must be JSON.', 400) };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return isRecord(body)
|
|
482
|
+
? { value: body }
|
|
483
|
+
: { error: errorResponse('validation_error', 'Request body must be a JSON object.', 400) };
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const parseJsonSourceOption = (body: Record<string, unknown>): ParseResult<ConversationSource> => {
|
|
487
|
+
const sourceOption = getStringOption(body, 'source', 'source');
|
|
488
|
+
if ('error' in sourceOption) {
|
|
489
|
+
return sourceOption;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const source = sourceOption.value?.trim();
|
|
493
|
+
if (!source) {
|
|
494
|
+
return { error: errorResponse('validation_error', '`source` is required.', 400, { field: 'source' }) };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return isConversationSource(source) ? { value: source } : { error: invalidSourceResponse(source) };
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const parseJsonIdsOption = (
|
|
501
|
+
body: Record<string, unknown>,
|
|
502
|
+
options: { destructive: boolean },
|
|
503
|
+
): ParseResult<string[]> => {
|
|
504
|
+
const idsValue = getOption(body, 'ids', 'ids');
|
|
505
|
+
if (!Array.isArray(idsValue) || idsValue.length === 0) {
|
|
506
|
+
return {
|
|
507
|
+
error: invalidFieldResponse('ids', idsValue, '`ids` must be a non-empty array of explicit ids.'),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (idsValue.length > MAX_ID_BATCH_SIZE) {
|
|
512
|
+
return {
|
|
513
|
+
error: invalidFieldResponse(
|
|
514
|
+
'ids',
|
|
515
|
+
idsValue.length,
|
|
516
|
+
`\`ids\` cannot include more than ${MAX_ID_BATCH_SIZE} ids.`,
|
|
517
|
+
),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const ids: string[] = [];
|
|
522
|
+
const seenIds = new Set<string>();
|
|
523
|
+
for (const [index, value] of idsValue.entries()) {
|
|
524
|
+
if (typeof value !== 'string') {
|
|
525
|
+
return {
|
|
526
|
+
error: invalidFieldResponse('ids', value, `\`ids[${index}]\` must be a string.`),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const id = value.trim();
|
|
531
|
+
if (!id || id.length > MAX_ID_LENGTH) {
|
|
532
|
+
return {
|
|
533
|
+
error: invalidFieldResponse('ids', value, `\`ids[${index}]\` is invalid.`),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const deleteIdError = options.destructive ? validateDeleteId(id) : null;
|
|
538
|
+
if (deleteIdError) {
|
|
539
|
+
return { error: deleteIdError };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!seenIds.has(id)) {
|
|
543
|
+
seenIds.add(id);
|
|
544
|
+
ids.push(id);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return { value: ids };
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const parseJsonExportFormat = (body: Record<string, unknown>): ParseResult<'md'> => {
|
|
552
|
+
const outputFormat = getStringOption(body, 'outputFormat', 'output_format');
|
|
553
|
+
if ('error' in outputFormat) {
|
|
554
|
+
return outputFormat;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (outputFormat.value === undefined || outputFormat.value === 'md') {
|
|
558
|
+
return { value: 'md' };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return { error: invalidFieldResponse('output_format', outputFormat.value, '`output_format` must be "md".') };
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const parseJsonExportMessageSelector = (body: Record<string, unknown>): ParseResult<ConversationMessageSelector> => {
|
|
565
|
+
const messageSelectorValue = getStringOption(body, 'messageSelector', 'message_selector');
|
|
566
|
+
if ('error' in messageSelectorValue) {
|
|
567
|
+
return messageSelectorValue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return parseMessageSelector(messageSelectorValue.value ?? null, 'all');
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const parseConversationIdSetRecord = (
|
|
574
|
+
body: Record<string, unknown>,
|
|
575
|
+
options: { destructive: boolean },
|
|
576
|
+
): ParseResult<DeleteConversationsOptions> => {
|
|
577
|
+
const source = parseJsonSourceOption(body);
|
|
578
|
+
if ('error' in source) {
|
|
579
|
+
return source;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const ids = parseJsonIdsOption(body, options);
|
|
583
|
+
if ('error' in ids) {
|
|
584
|
+
return ids;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
value: {
|
|
589
|
+
ids: ids.value,
|
|
590
|
+
source: source.value,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const parseExportConversationsBody = async (request: Request): Promise<ParseResult<ExportConversationsZipOptions>> => {
|
|
596
|
+
const body = await parseJsonBody(request);
|
|
597
|
+
if ('error' in body) {
|
|
598
|
+
return body;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const idSet = parseConversationIdSetRecord(body.value, { destructive: false });
|
|
602
|
+
if ('error' in idSet) {
|
|
603
|
+
return idSet;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const outputFormat = parseJsonExportFormat(body.value);
|
|
607
|
+
if ('error' in outputFormat) {
|
|
608
|
+
return outputFormat;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const messageSelector = parseJsonExportMessageSelector(body.value);
|
|
612
|
+
if ('error' in messageSelector) {
|
|
613
|
+
return messageSelector;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
value: {
|
|
618
|
+
ids: idSet.value.ids,
|
|
619
|
+
messageSelector: messageSelector.value,
|
|
620
|
+
outputFormat: outputFormat.value,
|
|
621
|
+
source: idSet.value.source,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const handleDeleteConversations = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
|
|
627
|
+
const body = await parseJsonBody(request);
|
|
628
|
+
if ('error' in body) {
|
|
629
|
+
return body.error;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const result = parseConversationIdSetRecord(body.value, { destructive: true });
|
|
633
|
+
if ('error' in result) {
|
|
634
|
+
return result.error;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const deleteResult = await dependencies.deleteConversations(result.value);
|
|
638
|
+
if (!deleteResult) {
|
|
639
|
+
return errorResponse(
|
|
640
|
+
'unsupported_operation',
|
|
641
|
+
`Deleting ${result.value.source} conversations is not supported by the stable API.`,
|
|
642
|
+
405,
|
|
643
|
+
{
|
|
644
|
+
source: result.value.source,
|
|
645
|
+
},
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (deleteResult.deletedIds.length === 0) {
|
|
650
|
+
return errorResponse('conversation_not_found', 'No conversations exist for that source and id set.', 404, {
|
|
651
|
+
ids: result.value.ids,
|
|
652
|
+
source: result.value.source,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return jsonResponse({ data: deleteResult });
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const getConversationZipEntry = (conversation: ConversationDetail, markdown: string) => ({
|
|
660
|
+
fallbackBaseName: `${conversation.source}-${conversation.id}`,
|
|
661
|
+
markdown,
|
|
662
|
+
title: conversation.title,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const handleExportConversations = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
|
|
666
|
+
const result = await parseExportConversationsBody(request);
|
|
667
|
+
if ('error' in result) {
|
|
668
|
+
return result.error;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const loaded = await mapWithConcurrency(result.value.ids, BATCH_LOAD_CONCURRENCY, async (id) => {
|
|
672
|
+
const conversation = await dependencies.getConversation({
|
|
673
|
+
id,
|
|
674
|
+
messageSelector: result.value.messageSelector,
|
|
675
|
+
source: result.value.source,
|
|
676
|
+
});
|
|
677
|
+
if (!conversation) {
|
|
678
|
+
return { entry: null, id };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
entry: getConversationZipEntry(
|
|
683
|
+
conversation,
|
|
684
|
+
dependencies.renderConversationMarkdown(conversation, {
|
|
685
|
+
messageSelector: result.value.messageSelector,
|
|
686
|
+
}),
|
|
687
|
+
),
|
|
688
|
+
id,
|
|
689
|
+
};
|
|
690
|
+
});
|
|
691
|
+
const missingIds = loaded.filter(({ entry }) => !entry).map(({ id }) => id);
|
|
692
|
+
|
|
693
|
+
if (missingIds.length > 0) {
|
|
694
|
+
return errorResponse(
|
|
695
|
+
'conversation_not_found',
|
|
696
|
+
'Some conversations do not exist for that source and id set.',
|
|
697
|
+
404,
|
|
698
|
+
{
|
|
699
|
+
ids: missingIds,
|
|
700
|
+
source: result.value.source,
|
|
701
|
+
},
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const zip = await createConversationMarkdownZip({
|
|
706
|
+
entries: loaded.flatMap(({ entry }) => (entry ? [entry] : [])),
|
|
707
|
+
fileBaseName: `${result.value.source}-conversations-${result.value.ids.length}`,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return new Response(zip.blob, {
|
|
364
711
|
headers: {
|
|
365
712
|
'Cache-Control': 'no-store',
|
|
366
|
-
'Content-
|
|
713
|
+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(zip.fileName)}`,
|
|
714
|
+
'Content-Type': zip.mimeType,
|
|
367
715
|
'X-Content-Type-Options': 'nosniff',
|
|
368
716
|
},
|
|
369
717
|
});
|
|
@@ -685,6 +1033,24 @@ const API_ROUTES: ApiRoute[] = [
|
|
|
685
1033
|
method: 'GET',
|
|
686
1034
|
resource: 'conversations',
|
|
687
1035
|
},
|
|
1036
|
+
{
|
|
1037
|
+
handle: ({ dependencies, id, source }) => handleDeleteConversation(source, id, dependencies),
|
|
1038
|
+
matches: ({ action, id, source }) => Boolean(source && id && !action),
|
|
1039
|
+
method: 'DELETE',
|
|
1040
|
+
resource: 'conversations',
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
handle: ({ dependencies, request }) => handleDeleteConversations(request, dependencies),
|
|
1044
|
+
matches: ({ action, source }) => !source && action === 'delete',
|
|
1045
|
+
method: 'POST',
|
|
1046
|
+
resource: 'conversations',
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
handle: ({ dependencies, request }) => handleExportConversations(request, dependencies),
|
|
1050
|
+
matches: ({ action, source }) => !source && action === 'export',
|
|
1051
|
+
method: 'POST',
|
|
1052
|
+
resource: 'conversations',
|
|
1053
|
+
},
|
|
688
1054
|
{
|
|
689
1055
|
handle: ({ dependencies, url }) => handleResolve(url, dependencies),
|
|
690
1056
|
matches: ({ source }) => !source,
|
|
@@ -716,6 +1082,22 @@ const allowedMethodsForContext = (context: ApiRouteContext) => {
|
|
|
716
1082
|
].sort();
|
|
717
1083
|
};
|
|
718
1084
|
|
|
1085
|
+
const parseConversationApiSegments = (segments: string[], resource: string) => {
|
|
1086
|
+
if (segments.length === 3) {
|
|
1087
|
+
return { action: undefined, id: undefined, resource, source: undefined };
|
|
1088
|
+
}
|
|
1089
|
+
if (segments.length === 4 && (segments[3] === 'delete' || segments[3] === 'export')) {
|
|
1090
|
+
return { action: segments[3], id: undefined, resource, source: undefined };
|
|
1091
|
+
}
|
|
1092
|
+
if (segments.length === 5) {
|
|
1093
|
+
return { action: undefined, id: segments[4], resource, source: segments[3] };
|
|
1094
|
+
}
|
|
1095
|
+
if (segments.length === 6 && segments[5] === 'export') {
|
|
1096
|
+
return { action: 'export', id: segments[4], resource, source: segments[3] };
|
|
1097
|
+
}
|
|
1098
|
+
return { action: '__invalid__', id: undefined, resource, source: undefined };
|
|
1099
|
+
};
|
|
1100
|
+
|
|
719
1101
|
const parseApiSegments = (segments: string[]) => {
|
|
720
1102
|
if (segments.length < 3 || segments[0] !== 'api' || segments[1] !== 'v1') {
|
|
721
1103
|
return null;
|
|
@@ -727,23 +1109,12 @@ const parseApiSegments = (segments: string[]) => {
|
|
|
727
1109
|
}
|
|
728
1110
|
|
|
729
1111
|
if (resource === 'conversations') {
|
|
730
|
-
|
|
731
|
-
return { action: undefined, id: undefined, resource, source: undefined };
|
|
732
|
-
}
|
|
733
|
-
if (segments.length === 5) {
|
|
734
|
-
return { action: undefined, id: segments[4], resource, source: segments[3] };
|
|
735
|
-
}
|
|
736
|
-
if (segments.length === 6 && segments[5] === 'export') {
|
|
737
|
-
return { action: 'export', id: segments[4], resource, source: segments[3] };
|
|
738
|
-
}
|
|
739
|
-
return { action: '__invalid__', id: undefined, resource, source: undefined };
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (segments.length === 3) {
|
|
743
|
-
return { action: undefined, id: undefined, resource, source: undefined };
|
|
1112
|
+
return parseConversationApiSegments(segments, resource);
|
|
744
1113
|
}
|
|
745
1114
|
|
|
746
|
-
return
|
|
1115
|
+
return segments.length === 3
|
|
1116
|
+
? { action: undefined, id: undefined, resource, source: undefined }
|
|
1117
|
+
: { action: '__invalid__', id: undefined, resource, source: undefined };
|
|
747
1118
|
};
|
|
748
1119
|
|
|
749
1120
|
export const handleConversationApiRequest = async (
|
|
@@ -776,7 +1147,16 @@ export const handleConversationApiRequest = async (
|
|
|
776
1147
|
const route = findRoute(context);
|
|
777
1148
|
|
|
778
1149
|
if (route) {
|
|
779
|
-
|
|
1150
|
+
try {
|
|
1151
|
+
return await route.handle(context);
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
console.error('[spiracha:conversation-api] request_failed', {
|
|
1154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1155
|
+
method: request.method,
|
|
1156
|
+
pathname: url.pathname,
|
|
1157
|
+
});
|
|
1158
|
+
return errorResponse('internal_error', 'Conversation API request failed.', 500);
|
|
1159
|
+
}
|
|
780
1160
|
}
|
|
781
1161
|
|
|
782
1162
|
if (parsed.resource && API_RESOURCES.has(parsed.resource)) {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
deleteAntigravityConversation,
|
|
3
|
+
listAntigravityConversations,
|
|
4
|
+
readAntigravityConversationMessages,
|
|
5
|
+
} from '../antigravity-db';
|
|
2
6
|
import type { AntigravityConversation } from '../antigravity-exporter-types';
|
|
3
7
|
import { resolveAntigravityRoots } from '../antigravity-exporter-types';
|
|
4
8
|
import { cleanInlineTitle } from '../shared';
|
|
9
|
+
import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
|
|
5
10
|
import { createDeepLinks, decodeFileUri, finalizeMessages } from './adapter-helpers';
|
|
6
11
|
import { selectConversationMessages } from './message-selector';
|
|
7
12
|
import { getConversationPathMatch, getFirstConversationPathMatch } from './path-match';
|
|
@@ -10,6 +15,7 @@ import type {
|
|
|
10
15
|
ConversationDetail,
|
|
11
16
|
ConversationMessage,
|
|
12
17
|
ConversationPathMatch,
|
|
18
|
+
DeleteConversationOptions,
|
|
13
19
|
GetConversationOptions,
|
|
14
20
|
ListConversationsForPathOptions,
|
|
15
21
|
} from './types';
|
|
@@ -27,7 +33,11 @@ const extractAbsolutePathReferences = (text: string): string[] => {
|
|
|
27
33
|
};
|
|
28
34
|
|
|
29
35
|
const readMessages = async (conversation: AntigravityConversation) => {
|
|
30
|
-
const messages = await readAntigravityConversationMessages(conversation)
|
|
36
|
+
const messages = await runWithTranscriptLoadLimit(() => readAntigravityConversationMessages(conversation), {
|
|
37
|
+
id: conversation.conversationId,
|
|
38
|
+
path: conversation.transcriptPath ?? conversation.conversationPath ?? undefined,
|
|
39
|
+
source: 'antigravity-api',
|
|
40
|
+
});
|
|
31
41
|
return finalizeMessages(
|
|
32
42
|
messages.map(
|
|
33
43
|
(message): ConversationMessage => ({
|
|
@@ -141,7 +151,16 @@ const getAntigravityConversation = async (options: GetConversationOptions): Prom
|
|
|
141
151
|
: null;
|
|
142
152
|
};
|
|
143
153
|
|
|
154
|
+
const deleteAntigravityConversationById = async (options: DeleteConversationOptions) => {
|
|
155
|
+
const result = await deleteAntigravityConversation(getRoots(options), options.id);
|
|
156
|
+
return {
|
|
157
|
+
deletedFiles: result.deletedPaths,
|
|
158
|
+
deletedIds: result.deletedConversationIds,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
144
162
|
export const antigravityConversationAdapter: ConversationAdapter = {
|
|
163
|
+
deleteConversation: deleteAntigravityConversationById,
|
|
145
164
|
getConversation: getAntigravityConversation,
|
|
146
165
|
listConversationsForPath: listAntigravityConversationsForPath,
|
|
147
166
|
source: 'antigravity',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
deleteClaudeCodeSession,
|
|
2
3
|
listClaudeCodeSessionsForGroup,
|
|
3
4
|
listClaudeCodeWorkspaceGroups,
|
|
4
5
|
readClaudeCodeSessionTranscript,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
} from '../claude-code-exporter-types';
|
|
12
13
|
import { getClaudeCodeAssistantMessagePhase, resolveClaudeCodeProjectsDir } from '../claude-code-exporter-types';
|
|
13
14
|
import { cleanInlineTitle } from '../shared';
|
|
15
|
+
import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
|
|
14
16
|
import {
|
|
15
17
|
createDeepLinks,
|
|
16
18
|
createTextMessage,
|
|
@@ -26,6 +28,7 @@ import type {
|
|
|
26
28
|
ConversationDetail,
|
|
27
29
|
ConversationMessage,
|
|
28
30
|
ConversationPathMatch,
|
|
31
|
+
DeleteConversationOptions,
|
|
29
32
|
GetConversationOptions,
|
|
30
33
|
ListConversationsForPathOptions,
|
|
31
34
|
} from './types';
|
|
@@ -102,9 +105,15 @@ const buildConversation = async (
|
|
|
102
105
|
projectsDir: string,
|
|
103
106
|
matches: ConversationPathMatch[],
|
|
104
107
|
options: Pick<ListConversationsForPathOptions, 'includeMessages' | 'messageSelector'>,
|
|
108
|
+
loadedTranscript: ClaudeCodeSessionTranscript | null = null,
|
|
105
109
|
): Promise<ConversationDetail> => {
|
|
106
110
|
const transcript = options.includeMessages
|
|
107
|
-
?
|
|
111
|
+
? (loadedTranscript ??
|
|
112
|
+
(await runWithTranscriptLoadLimit(() => readClaudeCodeSessionTranscript(projectsDir, session.sessionId), {
|
|
113
|
+
id: session.sessionId,
|
|
114
|
+
path: session.filePath,
|
|
115
|
+
source: 'claude-code-api',
|
|
116
|
+
})))
|
|
108
117
|
: null;
|
|
109
118
|
const allMessages = transcript ? transcriptToMessages(transcript) : [];
|
|
110
119
|
const messages = options.includeMessages
|
|
@@ -157,18 +166,40 @@ const listClaudeConversationsForPath = async (
|
|
|
157
166
|
|
|
158
167
|
const getClaudeConversation = async (options: GetConversationOptions): Promise<ConversationDetail | null> => {
|
|
159
168
|
const projectsDir = getProjectsDir(options);
|
|
160
|
-
const transcript = await
|
|
169
|
+
const transcript = await runWithTranscriptLoadLimit(
|
|
170
|
+
() => readClaudeCodeSessionTranscript(projectsDir, options.id),
|
|
171
|
+
{
|
|
172
|
+
id: options.id,
|
|
173
|
+
path: projectsDir,
|
|
174
|
+
source: 'claude-code-api',
|
|
175
|
+
},
|
|
176
|
+
);
|
|
161
177
|
if (!transcript) {
|
|
162
178
|
return null;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
return buildConversation(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
return buildConversation(
|
|
182
|
+
transcript.session,
|
|
183
|
+
projectsDir,
|
|
184
|
+
[],
|
|
185
|
+
{
|
|
186
|
+
includeMessages: true,
|
|
187
|
+
messageSelector: options.messageSelector ?? 'all',
|
|
188
|
+
},
|
|
189
|
+
transcript,
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const deleteClaudeConversation = async (options: DeleteConversationOptions) => {
|
|
194
|
+
const result = await deleteClaudeCodeSession(getProjectsDir(options), options.id);
|
|
195
|
+
return {
|
|
196
|
+
deletedFiles: result.deletedFiles,
|
|
197
|
+
deletedIds: result.deletedSessionIds,
|
|
198
|
+
};
|
|
169
199
|
};
|
|
170
200
|
|
|
171
201
|
export const claudeCodeConversationAdapter: ConversationAdapter = {
|
|
202
|
+
deleteConversation: deleteClaudeConversation,
|
|
172
203
|
getConversation: getClaudeConversation,
|
|
173
204
|
listConversationsForPath: listClaudeConversationsForPath,
|
|
174
205
|
source: 'claude-code',
|