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.
Files changed (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
@@ -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 = 'conversation_not_found' | 'method_not_allowed' | 'not_found' | 'validation_error';
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(dependencies.renderConversationMarkdown(conversation), {
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-Type': 'text/markdown; charset=utf-8',
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
- if (segments.length === 3) {
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 { action: '__invalid__', id: undefined, resource, source: undefined };
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
- return route.handle(context);
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 { listAntigravityConversations, readAntigravityConversationMessages } from '../antigravity-db';
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
- ? await readClaudeCodeSessionTranscript(projectsDir, session.sessionId)
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 readClaudeCodeSessionTranscript(projectsDir, options.id);
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(transcript.session, projectsDir, [], {
166
- includeMessages: true,
167
- messageSelector: options.messageSelector ?? 'all',
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',