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,4 +1,5 @@
1
- import { readdir, rm, stat } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, readdir, rename, rm, stat } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import {
4
5
  type AntigravityArtifact,
@@ -12,7 +13,17 @@ import {
12
13
  } from './antigravity-exporter-types';
13
14
  import { decryptAntigravitySafeStoragePayload } from './antigravity-keychain';
14
15
  import { resolveAntigravityProjectNames } from './antigravity-projects';
16
+ import { getAntigravityAssistantPhase, getFinalAntigravityAssistantSequences } from './antigravity-transcript-phase';
15
17
  import { mapWithConcurrency } from './concurrency';
18
+ import {
19
+ type ExportFormat,
20
+ formatInlineLiteral,
21
+ type MetadataEntry,
22
+ renderCodeBlock,
23
+ renderDocumentTitle,
24
+ renderMetadataBlock,
25
+ renderSection,
26
+ } from './shared';
16
27
 
17
28
  type ProtoField = {
18
29
  bytes?: Uint8Array;
@@ -66,11 +77,19 @@ export type AntigravityConversationMessage = {
66
77
  createdAtMs: number | null;
67
78
  metadata: Record<string, unknown>;
68
79
  order: number;
69
- phase: 'final_answer' | 'reasoning' | 'tool_call' | 'tool_output' | 'unknown';
80
+ phase: 'commentary' | 'final_answer' | 'reasoning' | 'tool_call' | 'tool_output' | 'unknown';
70
81
  role: 'assistant' | 'system' | 'tool' | 'unknown' | 'user';
71
82
  text: string;
72
83
  };
73
84
 
85
+ export type AntigravityConversationRenderOptions = {
86
+ includeCommentary?: boolean;
87
+ includeMetadata?: boolean;
88
+ includeTools?: boolean;
89
+ keychainSecret?: string | null;
90
+ outputFormat?: ExportFormat;
91
+ };
92
+
74
93
  type WorkspaceInfo = Pick<SummaryEntry, 'workspaceFolder' | 'workspaceKey' | 'workspaceLabel' | 'workspaceUri'>;
75
94
 
76
95
  const UNKNOWN_WORKSPACE: WorkspaceInfo = {
@@ -400,7 +419,15 @@ const removeConversationFromSummaryIndex = async (summaryPath: string, conversat
400
419
  return false;
401
420
  }
402
421
 
403
- await Bun.write(summaryPath, Buffer.concat(retained));
422
+ const tempPath = `${summaryPath}.${randomUUID()}.tmp`;
423
+ const mode = (await stat(summaryPath)).mode & 0o777;
424
+ try {
425
+ await Bun.write(tempPath, Buffer.concat(retained));
426
+ await chmod(tempPath, mode);
427
+ await rename(tempPath, summaryPath);
428
+ } finally {
429
+ await rm(tempPath, { force: true });
430
+ }
404
431
  return true;
405
432
  };
406
433
 
@@ -577,15 +604,6 @@ const mergeArtifactMaps = async (roots: string[]): Promise<Map<string, Antigravi
577
604
  return merged;
578
605
  };
579
606
 
580
- const countJsonlEntries = async (filePath: string): Promise<number> => {
581
- try {
582
- const text = await Bun.file(filePath).text();
583
- return text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length;
584
- } catch {
585
- return 0;
586
- }
587
- };
588
-
589
607
  const stripModelQualifier = (value: string): string => value.replace(/\s*\([^)]*\)\s*$/u, '').trim();
590
608
 
591
609
  const extractModelSelection = (content: string): string | null => {
@@ -610,18 +628,31 @@ const extractModelSelection = (content: string): string | null => {
610
628
  return rawModel ? stripModelQualifier(rawModel) : null;
611
629
  };
612
630
 
613
- const extractTranscriptModel = async (filePath: string): Promise<string | null> => {
614
- const text = await Bun.file(filePath)
615
- .text()
616
- .catch(() => '');
631
+ const transcriptAnalysisCache = new Map<string, { entryCount: number; model: string | null }>();
632
+
633
+ const analyzeTranscriptFile = async (filePath: string, size: number, mtimeMs: number) => {
634
+ const cacheKey = `${filePath}:${size}:${mtimeMs}`;
635
+ const cached = transcriptAnalysisCache.get(cacheKey);
636
+ if (cached) {
637
+ return cached;
638
+ }
639
+ const text = await Bun.file(filePath).text();
640
+ let model: string | null = null;
617
641
  for (const entry of parseLogEntries(text)) {
618
- const model = extractModelSelection(getString(entry.content) ?? '');
619
- if (model) {
620
- return model;
642
+ model = extractModelSelection(getString(entry.content) ?? '') ?? model;
643
+ if (model !== null) {
644
+ break;
621
645
  }
622
646
  }
623
-
624
- return null;
647
+ const analysis = {
648
+ entryCount: text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length,
649
+ model,
650
+ };
651
+ if (transcriptAnalysisCache.size >= 256) {
652
+ transcriptAnalysisCache.delete(transcriptAnalysisCache.keys().next().value ?? '');
653
+ }
654
+ transcriptAnalysisCache.set(cacheKey, analysis);
655
+ return analysis;
625
656
  };
626
657
 
627
658
  const preferTranscriptFile = (current: TranscriptFile | undefined, candidate: TranscriptFile): TranscriptFile => {
@@ -658,11 +689,12 @@ const readTranscriptFileCandidate = async (
658
689
  if (!info.isFile()) {
659
690
  return null;
660
691
  }
692
+ const analysis = await analyzeTranscriptFile(transcriptPath, info.size, info.mtimeMs);
661
693
 
662
694
  return {
663
695
  bytes: info.size,
664
- entryCount: await countJsonlEntries(transcriptPath),
665
- model: await extractTranscriptModel(transcriptPath),
696
+ entryCount: analysis.entryCount,
697
+ model: analysis.model,
666
698
  mtimeMs: info.mtimeMs,
667
699
  path: transcriptPath,
668
700
  root,
@@ -1043,48 +1075,99 @@ const logEntryHeading = (entry: AntigravityLogEntry): string => {
1043
1075
  return type ? `Tool: ${type}` : 'Event';
1044
1076
  };
1045
1077
 
1046
- const renderToolCalls = (toolCalls: unknown): string[] => {
1078
+ type ResolvedAntigravityConversationRenderOptions = {
1079
+ includeCommentary: boolean;
1080
+ includeMetadata: boolean;
1081
+ includeTools: boolean;
1082
+ keychainSecret: string | null;
1083
+ outputFormat: ExportFormat;
1084
+ };
1085
+
1086
+ const resolveAntigravityRenderOptions = (
1087
+ options: AntigravityConversationRenderOptions,
1088
+ ): ResolvedAntigravityConversationRenderOptions => ({
1089
+ includeCommentary: options.includeCommentary ?? true,
1090
+ includeMetadata: options.includeMetadata ?? true,
1091
+ includeTools: options.includeTools ?? true,
1092
+ keychainSecret: options.keychainSecret ?? null,
1093
+ outputFormat: options.outputFormat ?? 'md',
1094
+ });
1095
+
1096
+ const renderNestedHeading = (title: string, outputFormat: ExportFormat) => {
1097
+ return outputFormat === 'md' ? `### ${title}` : `${title}\n${'~'.repeat(Math.max(title.length, 3))}`;
1098
+ };
1099
+
1100
+ const renderToolCalls = (toolCalls: unknown, outputFormat: ExportFormat): string => {
1047
1101
  if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
1048
- return [];
1102
+ return '';
1049
1103
  }
1050
1104
 
1051
- const parts = ['### Tool Calls', ''];
1105
+ const parts: string[] = [];
1052
1106
  for (const call of toolCalls) {
1053
1107
  if (!call || typeof call !== 'object') {
1054
1108
  continue;
1055
1109
  }
1056
1110
 
1057
1111
  const { args, name } = call as { args?: unknown; name?: unknown };
1058
- parts.push(`- \`${typeof name === 'string' ? name : 'unknown'}\``);
1112
+ parts.push(`- ${formatInlineLiteral(typeof name === 'string' ? name : 'unknown', outputFormat)}`);
1059
1113
  if (args !== undefined) {
1060
- parts.push('', '```json', JSON.stringify(args, null, 2), '```', '');
1114
+ parts.push('', 'Input:', '', renderCodeBlock(JSON.stringify(args, null, 2), outputFormat), '');
1061
1115
  }
1062
1116
  }
1063
1117
 
1064
- return parts;
1118
+ return parts.length > 0 ? [renderNestedHeading('Tool Calls', outputFormat), '', ...parts].join('\n').trimEnd() : '';
1065
1119
  };
1066
1120
 
1067
- const renderLogEntry = (entry: AntigravityLogEntry): string[] => {
1068
- const heading = logEntryHeading(entry);
1069
- const timestamp = getString(entry.created_at);
1070
- const content = cleanLogContent(entry);
1121
+ const renderLogEntryBodyParts = (
1122
+ entry: AntigravityLogEntry,
1123
+ role: AntigravityConversationMessage['role'],
1124
+ sequence: number,
1125
+ finalAssistantSequences: Set<number>,
1126
+ options: ResolvedAntigravityConversationRenderOptions,
1127
+ ): string[] => {
1128
+ const bodyParts: string[] = [];
1071
1129
  const thinking = getString(entry.thinking) ?? '';
1072
- const parts = [`## ${heading}`, ''];
1130
+ if (options.includeCommentary && thinking) {
1131
+ bodyParts.push(renderNestedHeading('Thinking', options.outputFormat), '', thinking.trim(), '');
1132
+ }
1073
1133
 
1074
- if (timestamp) {
1075
- parts.push(`_Timestamp: ${timestamp}_`, '');
1134
+ const content = cleanLogContent(entry);
1135
+ const phase = role === 'assistant' ? getAntigravityAssistantPhase(sequence, finalAssistantSequences) : 'unknown';
1136
+ if (content && (role !== 'assistant' || phase !== 'commentary' || options.includeCommentary)) {
1137
+ bodyParts.push(content, '');
1076
1138
  }
1077
1139
 
1078
- if (thinking) {
1079
- parts.push('### Thinking', '', thinking.trim(), '');
1140
+ const toolCalls = options.includeTools ? renderToolCalls(entry.tool_calls, options.outputFormat) : '';
1141
+ if (toolCalls) {
1142
+ bodyParts.push(toolCalls);
1080
1143
  }
1081
1144
 
1082
- if (content) {
1083
- parts.push(content, '');
1145
+ return bodyParts;
1146
+ };
1147
+
1148
+ const renderLogEntry = (
1149
+ entry: AntigravityLogEntry,
1150
+ sequence: number,
1151
+ finalAssistantSequences: Set<number>,
1152
+ options: ResolvedAntigravityConversationRenderOptions,
1153
+ ): string => {
1154
+ const heading = logEntryHeading(entry);
1155
+ const role = logEntryRole(entry);
1156
+ if (!options.includeTools && (role === 'tool' || heading.startsWith('Tool: '))) {
1157
+ return '';
1084
1158
  }
1085
1159
 
1086
- parts.push(...renderToolCalls(entry.tool_calls));
1087
- return parts;
1160
+ const timestamp = getString(entry.created_at);
1161
+ const bodyParts = renderLogEntryBodyParts(entry, role, sequence, finalAssistantSequences, options);
1162
+
1163
+ if (bodyParts.length === 0) {
1164
+ return '';
1165
+ }
1166
+
1167
+ const parts = timestamp
1168
+ ? [options.outputFormat === 'md' ? `_Timestamp: ${timestamp}_` : `Timestamp: ${timestamp}`, '', ...bodyParts]
1169
+ : bodyParts;
1170
+ return renderSection(heading, parts.join('\n').trimEnd(), options.outputFormat);
1088
1171
  };
1089
1172
 
1090
1173
  const logEntryCreatedAtMs = (entry: AntigravityLogEntry): number | null => {
@@ -1122,10 +1205,14 @@ const logEntryRole = (entry: AntigravityLogEntry): AntigravityConversationMessag
1122
1205
  return 'unknown';
1123
1206
  };
1124
1207
 
1125
- const logEntryPhase = (entry: AntigravityLogEntry): AntigravityConversationMessage['phase'] => {
1208
+ const logEntryPhase = (
1209
+ entry: AntigravityLogEntry,
1210
+ sequence: number,
1211
+ finalAssistantSequences: Set<number>,
1212
+ ): AntigravityConversationMessage['phase'] => {
1126
1213
  const role = logEntryRole(entry);
1127
1214
  if (role === 'assistant') {
1128
- return 'final_answer';
1215
+ return getAntigravityAssistantPhase(sequence, finalAssistantSequences);
1129
1216
  }
1130
1217
  if (role === 'tool') {
1131
1218
  return 'tool_output';
@@ -1155,11 +1242,27 @@ const toolCallsText = (toolCalls: unknown): string => {
1155
1242
  .join('\n');
1156
1243
  };
1157
1244
 
1158
- const logEntryToMessages = (entry: AntigravityLogEntry, index: number): AntigravityConversationMessage[] => {
1245
+ const getAntigravityPhaseItems = (entries: AntigravityLogEntry[]) => {
1246
+ return entries.map((entry, sequence) => {
1247
+ const role = logEntryRole(entry);
1248
+ return {
1249
+ hasContent: Boolean(cleanLogContent(entry)),
1250
+ hasToolCalls: Array.isArray(entry.tool_calls) && entry.tool_calls.length > 0,
1251
+ role: role === 'assistant' || role === 'user' ? role : ('other' as const),
1252
+ sequence,
1253
+ };
1254
+ });
1255
+ };
1256
+
1257
+ const logEntryToMessages = (
1258
+ entry: AntigravityLogEntry,
1259
+ index: number,
1260
+ finalAssistantSequences: Set<number>,
1261
+ ): AntigravityConversationMessage[] => {
1159
1262
  const order = logEntryOrder(entry, index);
1160
1263
  const createdAtMs = logEntryCreatedAtMs(entry);
1161
1264
  const role = logEntryRole(entry);
1162
- const phase = logEntryPhase(entry);
1265
+ const phase = logEntryPhase(entry, index, finalAssistantSequences);
1163
1266
  const metadata = logEntryMetadata(entry);
1164
1267
  const messages: AntigravityConversationMessage[] = [];
1165
1268
  const thinking = getString(entry.thinking)?.trim();
@@ -1208,15 +1311,39 @@ export const readAntigravityConversationMessages = async (
1208
1311
  return [];
1209
1312
  }
1210
1313
 
1211
- try {
1212
- const entries = parseLogEntries(await Bun.file(conversation.transcriptPath).text());
1213
- return entries.flatMap(logEntryToMessages);
1214
- } catch {
1215
- return [];
1314
+ const content = await Bun.file(conversation.transcriptPath).text();
1315
+ const entries = parseLogEntries(content);
1316
+ if (content.trim() && entries.length === 0) {
1317
+ throw new Error(`Antigravity transcript is corrupt: ${conversation.transcriptPath}`);
1318
+ }
1319
+ const finalAssistantSequences = getFinalAntigravityAssistantSequences(getAntigravityPhaseItems(entries));
1320
+ return entries.flatMap((entry, index) => logEntryToMessages(entry, index, finalAssistantSequences));
1321
+ };
1322
+
1323
+ const renderAntigravityMetadata = (entries: MetadataEntry[], outputFormat: ExportFormat): string => {
1324
+ if (outputFormat === 'txt') {
1325
+ return renderMetadataBlock(entries, outputFormat);
1216
1326
  }
1327
+
1328
+ return entries
1329
+ .filter((entry) => entry.value !== null && entry.value !== undefined && entry.value !== '')
1330
+ .map((entry) => `- ${entry.key}: ${formatInlineLiteral(String(entry.value), outputFormat)}`)
1331
+ .join('\n');
1217
1332
  };
1218
1333
 
1219
- const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConversation): Promise<string | null> => {
1334
+ const buildAntigravityIdentityMetadata = (
1335
+ conversation: AntigravityConversation,
1336
+ exportedFrom: string,
1337
+ ): MetadataEntry[] => [
1338
+ { key: 'exported_from', value: exportedFrom },
1339
+ { key: 'conversation_id', value: conversation.conversationId },
1340
+ { key: 'workspace', value: conversation.workspaceUri },
1341
+ ];
1342
+
1343
+ const renderAntigravityTranscript = async (
1344
+ conversation: AntigravityConversation,
1345
+ options: ResolvedAntigravityConversationRenderOptions,
1346
+ ): Promise<string | null> => {
1220
1347
  if (!conversation.transcriptPath || !conversation.transcriptSource) {
1221
1348
  return null;
1222
1349
  }
@@ -1230,33 +1357,44 @@ const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConv
1230
1357
  conversation.transcriptSource === 'overview'
1231
1358
  ? 'antigravity_overview_transcript'
1232
1359
  : 'antigravity_jsonl_transcript';
1233
- const sections = entries.flatMap((entry) => renderLogEntry(entry));
1360
+ const finalAssistantSequences = getFinalAntigravityAssistantSequences(getAntigravityPhaseItems(entries));
1361
+ const sections = entries
1362
+ .map((entry, sequence) => renderLogEntry(entry, sequence, finalAssistantSequences, options))
1363
+ .filter(Boolean);
1234
1364
  const parts = [
1235
- `# ${conversation.title}`,
1236
- '',
1237
- `- exported_from: \`${exportedFrom}\``,
1238
- `- conversation_id: \`${conversation.conversationId}\``,
1239
- conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
1365
+ renderDocumentTitle(conversation.title, options.outputFormat),
1240
1366
  '',
1367
+ options.includeMetadata
1368
+ ? renderAntigravityMetadata(
1369
+ buildAntigravityIdentityMetadata(conversation, exportedFrom),
1370
+ options.outputFormat,
1371
+ )
1372
+ : '',
1241
1373
  ...sections,
1242
1374
  ].filter(Boolean);
1243
1375
 
1244
1376
  return `${parts.join('\n').trimEnd()}\n`;
1245
1377
  };
1246
1378
 
1247
- const renderDecryptedSafeStorageMarkdown = (conversation: AntigravityConversation, content: string): string | null => {
1379
+ const renderDecryptedSafeStorage = (
1380
+ conversation: AntigravityConversation,
1381
+ content: string,
1382
+ options: ResolvedAntigravityConversationRenderOptions,
1383
+ ): string | null => {
1248
1384
  const trimmed = content.trim();
1249
1385
  if (!trimmed) {
1250
1386
  return null;
1251
1387
  }
1252
1388
 
1253
1389
  return [
1254
- `# ${conversation.title}`,
1255
- '',
1256
- '- exported_from: `antigravity_safe_storage_payload`',
1257
- `- conversation_id: \`${conversation.conversationId}\``,
1258
- conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
1390
+ renderDocumentTitle(conversation.title, options.outputFormat),
1259
1391
  '',
1392
+ options.includeMetadata
1393
+ ? renderAntigravityMetadata(
1394
+ buildAntigravityIdentityMetadata(conversation, 'antigravity_safe_storage_payload'),
1395
+ options.outputFormat,
1396
+ )
1397
+ : '',
1260
1398
  trimmed,
1261
1399
  '',
1262
1400
  ]
@@ -1264,7 +1402,10 @@ const renderDecryptedSafeStorageMarkdown = (conversation: AntigravityConversatio
1264
1402
  .join('\n');
1265
1403
  };
1266
1404
 
1267
- const renderAntigravitySummaryMarkdown = (conversation: AntigravityConversation): string | null => {
1405
+ const renderAntigravitySummary = (
1406
+ conversation: AntigravityConversation,
1407
+ options: ResolvedAntigravityConversationRenderOptions,
1408
+ ): string | null => {
1268
1409
  if (conversation.artifactCount > 0) {
1269
1410
  return null;
1270
1411
  }
@@ -1274,16 +1415,30 @@ const renderAntigravitySummaryMarkdown = (conversation: AntigravityConversation)
1274
1415
  }
1275
1416
 
1276
1417
  return [
1277
- `# ${conversation.title}`,
1278
- '',
1279
- '- exported_from: `antigravity_summary_index`',
1280
- `- conversation_id: \`${conversation.conversationId}\``,
1281
- conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
1282
- conversation.indexedItemCount !== null ? `- indexed_items: \`${conversation.indexedItemCount}\`` : '',
1283
- conversation.createdAtMs ? `- created_at: \`${new Date(conversation.createdAtMs).toISOString()}\`` : '',
1284
- conversation.lastUpdatedAtMs ? `- updated_at: \`${new Date(conversation.lastUpdatedAtMs).toISOString()}\`` : '',
1418
+ renderDocumentTitle(conversation.title, options.outputFormat),
1285
1419
  '',
1286
- 'No local transcript log was found for this Antigravity conversation. The export contains the summary index metadata that Antigravity retained.',
1420
+ options.includeMetadata
1421
+ ? renderAntigravityMetadata(
1422
+ [
1423
+ ...buildAntigravityIdentityMetadata(conversation, 'antigravity_summary_index'),
1424
+ { key: 'indexed_items', value: conversation.indexedItemCount },
1425
+ {
1426
+ key: 'created_at',
1427
+ value: conversation.createdAtMs ? new Date(conversation.createdAtMs).toISOString() : null,
1428
+ },
1429
+ {
1430
+ key: 'updated_at',
1431
+ value: conversation.lastUpdatedAtMs
1432
+ ? new Date(conversation.lastUpdatedAtMs).toISOString()
1433
+ : null,
1434
+ },
1435
+ ],
1436
+ options.outputFormat,
1437
+ )
1438
+ : '',
1439
+ options.includeMetadata
1440
+ ? 'No local transcript log was found for this Antigravity conversation. The export contains the summary index metadata that Antigravity retained.'
1441
+ : 'No local transcript log was found for this Antigravity conversation.',
1287
1442
  '',
1288
1443
  ]
1289
1444
  .filter(Boolean)
@@ -1292,22 +1447,21 @@ const renderAntigravitySummaryMarkdown = (conversation: AntigravityConversation)
1292
1447
 
1293
1448
  export const renderAntigravityConversationMarkdown = async (
1294
1449
  conversation: AntigravityConversation,
1295
- options: {
1296
- keychainSecret?: string | null;
1297
- } = {},
1450
+ options: AntigravityConversationRenderOptions = {},
1298
1451
  ): Promise<string | null> => {
1299
- const transcript = await renderAntigravityTranscriptMarkdown(conversation);
1452
+ const resolvedOptions = resolveAntigravityRenderOptions(options);
1453
+ const transcript = await renderAntigravityTranscript(conversation, resolvedOptions);
1300
1454
  if (transcript) {
1301
1455
  return transcript;
1302
1456
  }
1303
1457
 
1304
- if (options.keychainSecret && conversation.conversationPath) {
1458
+ if (resolvedOptions.keychainSecret && conversation.conversationPath) {
1305
1459
  const encrypted = Buffer.from(await Bun.file(conversation.conversationPath).arrayBuffer());
1306
- const decrypted = decryptAntigravitySafeStoragePayload(encrypted, options.keychainSecret);
1460
+ const decrypted = decryptAntigravitySafeStoragePayload(encrypted, resolvedOptions.keychainSecret);
1307
1461
  if (decrypted) {
1308
- return renderDecryptedSafeStorageMarkdown(conversation, decrypted);
1462
+ return renderDecryptedSafeStorage(conversation, decrypted, resolvedOptions);
1309
1463
  }
1310
1464
  }
1311
1465
 
1312
- return renderAntigravitySummaryMarkdown(conversation);
1466
+ return renderAntigravitySummary(conversation, resolvedOptions);
1313
1467
  };
@@ -169,19 +169,18 @@ export const getAntigravityDecryptionState = ({
169
169
 
170
170
  export const getCachedAntigravityKeychainSecret = (): string | null => cachedKeychainSecret;
171
171
 
172
+ export const getAntigravityKeychainExecOptions = () => ({ timeout: 10_000 });
173
+
172
174
  export const readAntigravityKeychainSecret = async (): Promise<string> => {
173
175
  if (process.platform !== 'darwin') {
174
176
  throw new Error('Antigravity Keychain access is only available on macOS.');
175
177
  }
176
178
 
177
- const { stdout } = await execFileAsync('security', [
178
- 'find-generic-password',
179
- '-s',
180
- ANTIGRAVITY_KEYCHAIN_SERVICE,
181
- '-a',
182
- ANTIGRAVITY_KEYCHAIN_ACCOUNT,
183
- '-w',
184
- ]);
179
+ const { stdout } = await execFileAsync(
180
+ 'security',
181
+ ['find-generic-password', '-s', ANTIGRAVITY_KEYCHAIN_SERVICE, '-a', ANTIGRAVITY_KEYCHAIN_ACCOUNT, '-w'],
182
+ getAntigravityKeychainExecOptions(),
183
+ );
185
184
  const secret = stdout.trim();
186
185
  if (!secret) {
187
186
  throw new Error(`No secret was returned for ${ANTIGRAVITY_KEYCHAIN_SERVICE}.`);
@@ -0,0 +1,47 @@
1
+ export type AntigravityTranscriptPhaseItem = {
2
+ hasContent: boolean;
3
+ hasToolCalls: boolean;
4
+ role: 'assistant' | 'other' | 'user';
5
+ sequence: number;
6
+ };
7
+
8
+ export type AntigravityAssistantMessagePhase = 'commentary' | 'final_answer';
9
+
10
+ export const getFinalAntigravityAssistantSequences = (items: AntigravityTranscriptPhaseItem[]): Set<number> => {
11
+ const finalSequences = new Set<number>();
12
+ let latestAssistantContentSequence: number | null = null;
13
+
14
+ const flushAssistantRun = () => {
15
+ if (latestAssistantContentSequence !== null) {
16
+ finalSequences.add(latestAssistantContentSequence);
17
+ }
18
+ latestAssistantContentSequence = null;
19
+ };
20
+
21
+ for (const item of items) {
22
+ if (item.role === 'user') {
23
+ flushAssistantRun();
24
+ continue;
25
+ }
26
+
27
+ if (item.role !== 'assistant') {
28
+ continue;
29
+ }
30
+
31
+ if (item.hasToolCalls) {
32
+ latestAssistantContentSequence = null;
33
+ } else if (item.hasContent) {
34
+ latestAssistantContentSequence = item.sequence;
35
+ }
36
+ }
37
+
38
+ flushAssistantRun();
39
+ return finalSequences;
40
+ };
41
+
42
+ export const getAntigravityAssistantPhase = (
43
+ sequence: number,
44
+ finalAssistantSequences: Set<number>,
45
+ ): AntigravityAssistantMessagePhase => {
46
+ return finalAssistantSequences.has(sequence) ? 'final_answer' : 'commentary';
47
+ };