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.
Files changed (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. 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 { finished } from 'node:stream/promises';
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.pipe(outputStream, { end: false });
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
- if (parsed.type !== 'response_item') {
516
- return null;
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 (payload.type === 'function_call_output') {
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: 'function_call_output',
553
- outputText: outputText ?? undefined,
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 = stripCodexAppDirectiveLines(cleanExtractedText(stripPreviewBlock(extractedText)));
576
- if (!text || shouldSkipMessage(message.role, text)) {
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 details ? renderSection('Tool', details, outputFormat) : '';
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
- return summary ? renderSection('Tool Output', summary, outputFormat) : '';
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+.+$/i.test(value);
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 shouldSkipMessage = (role: string, text: string): boolean => {
624
- if (text.startsWith('<environment_context>')) {
625
- return true;
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 (role === 'user' && text.includes('<environment_context>')) {
637
- return true;
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
- return false;
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
- const details = parseExecCommandArguments(tool.argumentsText);
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 => {
@@ -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
- results[currentIndex] = await mapper(values[currentIndex]!, currentIndex);
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
- await Promise.all(Array.from({ length: Math.min(workerLimit, values.length) }, () => worker()));
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 = value
102
- .split(',')
103
- .map((source) => source.trim())
104
- .filter(Boolean);
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 validateDeleteId = (id: string): Response | null => {
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
- hasNext: meta.hasNext,
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 deleteIdError = validateDeleteId(decodedId);
374
- if (deleteIdError) {
375
- return { error: deleteIdError };
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 deleteIdError = options.destructive ? validateDeleteId(id) : null;
538
- if (deleteIdError) {
539
- return { error: deleteIdError };
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, options);
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, { destructive: false });
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, { destructive: true });
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 cwdLengthError = validatePathLength('cwd', cwd);
849
- return cwdLengthError ? { error: cwdLengthError } : { value: cwd };
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 pathValue = decodeURIComponent(value.slice('file://'.length));
40
- return pathValue.replace(/^\/([A-Za-z]:)/u, '$1');
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 { createDeepLinks, decodeFileUri, finalizeMessages } from './adapter-helpers';
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 messages = await runWithTranscriptLoadLimit(() => readAntigravityConversationMessages(conversation), {
37
- id: conversation.conversationId,
38
- path: conversation.transcriptPath ?? conversation.conversationPath ?? undefined,
39
- source: 'antigravity-api',
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
- `/antigravity-conversations/${conversation.conversationId}`,
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
- const messages = await readMessages(conversation);
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
- listClaudeCodeSessionsForGroup,
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('claude-code', session.sessionId, `/claude-code-sessions/${session.sessionId}`),
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 sessions = await listClaudeCodeSessionsForGroup(group.key, projectsDir);
159
- for (const session of sessions) {
160
- conversations.push(await buildConversation(session, projectsDir, [match], options));
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;