spiracha 1.2.0 → 1.3.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 (149) hide show
  1. package/AGENTS.md +49 -12
  2. package/README.md +117 -64
  3. package/apps/ui/AGENTS.md +16 -8
  4. package/apps/ui/README.md +28 -12
  5. package/apps/ui/dist/client/assets/{analytics-Cv0JMDN2.js → analytics-B_hYz65v.js} +1 -1
  6. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-qiyygB7e.js +1 -0
  7. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-z1SQC2Kg.js +1 -0
  8. package/apps/ui/dist/client/assets/antigravity-keychain-panel-dYuRWtCf.js +1 -0
  9. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CliqUr7o.js +1 -0
  10. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CnoBzyX6.js +1 -0
  11. package/apps/ui/dist/client/assets/antigravity.index-CakfZz_E.js +1 -0
  12. package/apps/ui/dist/client/assets/antigravity.index-DY7M1KhG.js +1 -0
  13. package/apps/ui/dist/client/assets/badge-aHE9ETVe.js +1 -0
  14. package/apps/ui/dist/client/assets/checkbox-DN3XnJaA.js +1 -0
  15. package/apps/ui/dist/client/assets/cursor-threads._composerId-BMQyx8qG.js +1 -0
  16. package/apps/ui/dist/client/assets/cursor-threads._composerId-BTlaA-tV.js +1 -0
  17. package/apps/ui/dist/client/assets/cursor._workspaceKey-CrgrfevV.js +1 -0
  18. package/apps/ui/dist/client/assets/cursor._workspaceKey-bYS2syGL.js +1 -0
  19. package/apps/ui/dist/client/assets/cursor.index-CTqZMPYU.js +1 -0
  20. package/apps/ui/dist/client/assets/cursor.index-Clsz4E_e.js +2 -0
  21. package/apps/ui/dist/client/assets/{data-table-Bgnh7phF.js → data-table-Cj-v-uyB.js} +2 -2
  22. package/apps/ui/dist/client/assets/delete-confirm-dialog-DTpzBiNK.js +11 -0
  23. package/apps/ui/dist/client/assets/dist-BNAn99Pu.js +1 -0
  24. package/apps/ui/dist/client/assets/download-P3Rp23Ad.js +1 -0
  25. package/apps/ui/dist/client/assets/dropdown-menu-3qB5j9nt.js +1 -0
  26. package/apps/ui/dist/client/assets/es2015-Dwm_turD.js +41 -0
  27. package/apps/ui/dist/client/assets/export-dialog-CazdrASq.js +1 -0
  28. package/apps/ui/dist/client/assets/formatters-BdnWuM1z.js +1 -0
  29. package/apps/ui/dist/client/assets/index-BVFnfS78.js +22 -0
  30. package/apps/ui/dist/client/assets/json-panel-DLkS30sQ.js +1 -0
  31. package/apps/ui/dist/client/assets/metadata-section-jnIkB7dB.js +1 -0
  32. package/apps/ui/dist/client/assets/{metric-card-BJX5rkHK.js → metric-card-CBZuWLzQ.js} +1 -1
  33. package/apps/ui/dist/client/assets/page-header-CnD21cPn.js +1 -0
  34. package/apps/ui/dist/client/assets/projects._project-BLszwvYL.js +1 -0
  35. package/apps/ui/dist/client/assets/projects._project-DvLxYbvk.js +1 -0
  36. package/apps/ui/dist/client/assets/projects.index-COn8woBR.js +1 -0
  37. package/apps/ui/dist/client/assets/projects.index-DYs98skV.js +3 -0
  38. package/apps/ui/dist/client/assets/refresh-ccw-BDrYXjtD.js +1 -0
  39. package/apps/ui/dist/client/assets/reload-error-panel-DLAg0AW2.js +1 -0
  40. package/apps/ui/dist/client/assets/routes-BtF5-coe.js +1 -0
  41. package/apps/ui/dist/client/assets/scroll-text-CqaFm9by.js +1 -0
  42. package/apps/ui/dist/client/assets/select-DbnpwqL6.js +1 -0
  43. package/apps/ui/dist/client/assets/settings-CGX3VleN.js +1 -0
  44. package/apps/ui/dist/client/assets/styles-Ch0r3kMZ.css +1 -0
  45. package/apps/ui/dist/client/assets/text-document-panel-DPleOmmq.js +1 -0
  46. package/apps/ui/dist/client/assets/text-filter-7M6wRo-t.js +2 -0
  47. package/apps/ui/dist/client/assets/threads._threadId-D5w76IB-.js +7 -0
  48. package/apps/ui/dist/client/assets/{threads._threadId-CUiCZSwo.js → threads._threadId-Dx85sI9P.js} +1 -1
  49. package/apps/ui/dist/client/assets/useMutation-MZ3Hr9h9.js +1 -0
  50. package/apps/ui/dist/client/assets/useQuery-Cb4V0AT0.js +1 -0
  51. package/apps/ui/dist/client/icon.svg +28 -0
  52. package/apps/ui/dist/client/manifest.json +6 -16
  53. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CBbkUXw6.js +227 -0
  54. package/apps/ui/dist/server/assets/{analytics-2QpLKjlG.js → analytics-CBNOYZwJ.js} +2 -2
  55. package/apps/ui/dist/server/assets/antigravity-conversation-state-HgzS302O.js +16 -0
  56. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-B9Rm4EXh.js +212 -0
  57. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-BIdYNy68.js +20 -0
  58. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-D426O-64.js +11 -0
  59. package/apps/ui/dist/server/assets/antigravity-db-D9gW1D8G.js +576 -0
  60. package/apps/ui/dist/server/assets/antigravity-keychain-DOiuHDwK.js +126 -0
  61. package/apps/ui/dist/server/assets/antigravity-keychain-panel-DcLyBBwd.js +55 -0
  62. package/apps/ui/dist/server/assets/antigravity-queries-CgQhlQ7J.js +37 -0
  63. package/apps/ui/dist/server/assets/antigravity-server-DFUx4Khk.js +114 -0
  64. package/apps/ui/dist/server/assets/antigravity._workspaceKey-3m_MzNFA.js +11 -0
  65. package/apps/ui/dist/server/assets/antigravity._workspaceKey-D42ixtzp.js +210 -0
  66. package/apps/ui/dist/server/assets/antigravity._workspaceKey-DnSlSC-C.js +28 -0
  67. package/apps/ui/dist/server/assets/antigravity.index-DZVT-cac.js +104 -0
  68. package/apps/ui/dist/server/assets/antigravity.index-DudTB3Tq.js +11 -0
  69. package/apps/ui/dist/server/assets/badge-EvdhKK_Z.js +26 -0
  70. package/apps/ui/dist/server/assets/{codex-queries-BH4Cb0v3.js → codex-queries-eOJGfHQj.js} +4 -16
  71. package/apps/ui/dist/server/assets/{codex-server-DqzruLmg.js → codex-server-nrETIF--.js} +149 -140
  72. package/apps/ui/dist/server/assets/createServerRpc-BtXIw2iP.js +12 -0
  73. package/apps/ui/dist/server/assets/createSsrRpc-COf5Zuye.js +16 -0
  74. package/apps/ui/dist/server/assets/cursor-db-B7agkAvM.js +643 -0
  75. package/apps/ui/dist/server/assets/cursor-exporter-types-CI3goo-c.js +34 -0
  76. package/apps/ui/dist/server/assets/cursor-queries-BMhuJeUO.js +65 -0
  77. package/apps/ui/dist/server/assets/cursor-recovery-9bJLs7vG.js +361 -0
  78. package/apps/ui/dist/server/assets/cursor-server-BgylIFgn.js +184 -0
  79. package/apps/ui/dist/server/assets/cursor-threads._composerId-BB0Y_Mao.js +11 -0
  80. package/apps/ui/dist/server/assets/cursor-threads._composerId-BsxFKzoJ.js +218 -0
  81. package/apps/ui/dist/server/assets/cursor-threads._composerId-DXffY_CK.js +18 -0
  82. package/apps/ui/dist/server/assets/cursor-transcript-2iL3KFSK.js +125 -0
  83. package/apps/ui/dist/server/assets/cursor._workspaceKey-BP2J1x_x.js +28 -0
  84. package/apps/ui/dist/server/assets/cursor._workspaceKey-BQd0e-Pd.js +399 -0
  85. package/apps/ui/dist/server/assets/cursor._workspaceKey-nmg3YIOQ.js +11 -0
  86. package/apps/ui/dist/server/assets/cursor.index-CQVxtCm8.js +189 -0
  87. package/apps/ui/dist/server/assets/cursor.index-CcsX7DG0.js +11 -0
  88. package/apps/ui/dist/server/assets/{delete-confirm-dialog-CWqcTXTF.js → delete-confirm-dialog-PCD7S0_M.js} +5 -4
  89. package/apps/ui/dist/server/assets/download-DMmiy1xf.js +92 -0
  90. package/apps/ui/dist/server/assets/{input-B4tEzctc.js → dropdown-menu-Dy_9t6TN.js} +1 -11
  91. package/apps/ui/dist/server/assets/{download-Drctxary.js → export-dialog-DaPlOGFT.js} +1 -92
  92. package/apps/ui/dist/server/assets/json-panel-RYsxWFae.js +16 -0
  93. package/apps/ui/dist/server/assets/{loading-panel-DbLdvjtR.js → loading-panel-BGFnWseS.js} +1 -1
  94. package/apps/ui/dist/server/assets/metadata-section-D6Lbc7D6.js +54 -0
  95. package/apps/ui/dist/server/assets/page-header-VNSaM3xd.js +29 -0
  96. package/apps/ui/dist/server/assets/projects._project-Bshqk7JA.js +12 -0
  97. package/apps/ui/dist/server/assets/{projects._project-gT01HBqH.js → projects._project-DUN3iWfg.js} +4 -4
  98. package/apps/ui/dist/server/assets/{projects._project-DreIU5b0.js → projects._project-Dim9Y0kD.js} +54 -26
  99. package/apps/ui/dist/server/assets/projects.index-BLXOx5eL.js +12 -0
  100. package/apps/ui/dist/server/assets/{projects.index-BYmgSGAj.js → projects.index-DjSQK5dm.js} +23 -27
  101. package/apps/ui/dist/server/assets/{projects.index-CaplpeMy.js → reload-error-panel-BJMxY3U1.js} +5 -6
  102. package/apps/ui/dist/server/assets/{router-Qj5Kn7bl.js → router-DrDgc-LD.js} +131 -44
  103. package/apps/ui/dist/server/assets/{routes-_LbCIjtJ.js → routes-B-GlEe2C.js} +54 -39
  104. package/apps/ui/dist/server/assets/{routes-BtcXuK0x.js → routes-CNHAUMwo.js} +2 -2
  105. package/apps/ui/dist/server/assets/{settings-MvWDgc1u.js → settings-OayxIYQQ.js} +1 -1
  106. package/apps/ui/dist/server/assets/shared-CPRNYIql.js +134 -0
  107. package/apps/ui/dist/server/assets/text-document-panel-D8JbQWAn.js +23 -0
  108. package/apps/ui/dist/server/assets/text-filter-CGKxMCKt.js +36 -0
  109. package/apps/ui/dist/server/assets/{threads._threadId-DcbAJkwf.js → threads._threadId-CJzm4KrZ.js} +3 -3
  110. package/apps/ui/dist/server/assets/{threads._threadId-D5m6ypGw.js → threads._threadId-DODTYddm.js} +69 -76
  111. package/apps/ui/dist/server/server.js +77 -13
  112. package/package.json +19 -9
  113. package/src/export-cursor.ts +244 -0
  114. package/src/lib/antigravity-db.ts +936 -0
  115. package/src/lib/antigravity-exporter-types.ts +70 -0
  116. package/src/lib/antigravity-keychain.ts +203 -0
  117. package/src/lib/codex-browser-db.ts +7 -1
  118. package/src/lib/codex-browser-types.ts +22 -1
  119. package/src/lib/codex-thread-recovery.ts +202 -0
  120. package/src/lib/cursor-db.ts +1096 -0
  121. package/src/lib/cursor-exporter-types.ts +190 -0
  122. package/src/lib/cursor-exporter.ts +266 -0
  123. package/src/lib/cursor-recovery.ts +543 -0
  124. package/src/lib/cursor-transcript.ts +183 -0
  125. package/src/spiracha.ts +16 -3
  126. package/src/ui-cli.ts +2 -2
  127. package/apps/ui/dist/client/assets/checkbox-DjHij7DJ.js +0 -1
  128. package/apps/ui/dist/client/assets/delete-confirm-dialog-CIZy_LXD.js +0 -11
  129. package/apps/ui/dist/client/assets/download-DQtfva4z.js +0 -1
  130. package/apps/ui/dist/client/assets/es2015-DsDKdYCE.js +0 -41
  131. package/apps/ui/dist/client/assets/formatters-CWFrMKSn.js +0 -1
  132. package/apps/ui/dist/client/assets/index-C_-e0lDI.js +0 -22
  133. package/apps/ui/dist/client/assets/input-BbgApiqZ.js +0 -1
  134. package/apps/ui/dist/client/assets/page-header-ODLuGLAB.js +0 -1
  135. package/apps/ui/dist/client/assets/projects._project-C2Pys_bB.js +0 -1
  136. package/apps/ui/dist/client/assets/projects._project-CHvAKvlu.js +0 -1
  137. package/apps/ui/dist/client/assets/projects.index-BmwtS1x-.js +0 -1
  138. package/apps/ui/dist/client/assets/projects.index-CuLw73mt.js +0 -1
  139. package/apps/ui/dist/client/assets/routes-CfnaTOlj.js +0 -1
  140. package/apps/ui/dist/client/assets/select-B1kH_5lx.js +0 -1
  141. package/apps/ui/dist/client/assets/settings-mYTB3sso.js +0 -1
  142. package/apps/ui/dist/client/assets/styles-CMrP9Jb4.css +0 -1
  143. package/apps/ui/dist/client/assets/threads._threadId-C_47okme.js +0 -7
  144. package/apps/ui/dist/client/favicon.ico +0 -0
  145. package/apps/ui/dist/client/logo192.png +0 -0
  146. package/apps/ui/dist/client/logo512.png +0 -0
  147. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-kj_QB_26.js +0 -99
  148. package/apps/ui/dist/server/assets/page-header-CxdZM86z.js +0 -25
  149. package/apps/ui/dist/server/assets/projects._project-CLSohrBp.js +0 -26
@@ -0,0 +1,936 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ type AntigravityArtifact,
5
+ type AntigravityConversation,
6
+ type AntigravityTranscriptSource,
7
+ type AntigravityWorkspaceGroup,
8
+ getAntigravityBrainDir,
9
+ getAntigravityConversationDir,
10
+ getAntigravitySummaryIndexPath,
11
+ resolveAntigravityRoots,
12
+ } from './antigravity-exporter-types';
13
+ import { decryptAntigravitySafeStoragePayload } from './antigravity-keychain';
14
+
15
+ type ProtoField = {
16
+ bytes?: Uint8Array;
17
+ fieldNumber: number;
18
+ value?: number;
19
+ wireType: number;
20
+ };
21
+
22
+ type SummaryEntry = {
23
+ conversationId: string;
24
+ createdAtMs: number | null;
25
+ indexedItemCount: number | null;
26
+ lastUpdatedAtMs: number | null;
27
+ summaryPath: string;
28
+ title: string;
29
+ workspaceFolder: string | null;
30
+ workspaceKey: string;
31
+ workspaceLabel: string;
32
+ workspaceUri: string | null;
33
+ };
34
+
35
+ type ConversationFile = {
36
+ bytes: number;
37
+ mtimeMs: number;
38
+ path: string;
39
+ root: string;
40
+ };
41
+
42
+ type TranscriptFile = {
43
+ bytes: number;
44
+ entryCount: number;
45
+ mtimeMs: number;
46
+ path: string;
47
+ root: string;
48
+ source: Exclude<AntigravityTranscriptSource, 'safe-storage'>;
49
+ };
50
+
51
+ type WorkspaceInfo = Pick<SummaryEntry, 'workspaceFolder' | 'workspaceKey' | 'workspaceLabel' | 'workspaceUri'>;
52
+
53
+ const UNKNOWN_WORKSPACE: WorkspaceInfo = {
54
+ workspaceFolder: null,
55
+ workspaceKey: 'unknown',
56
+ workspaceLabel: 'Unknown project',
57
+ workspaceUri: null,
58
+ };
59
+
60
+ const decoder = new TextDecoder();
61
+
62
+ const pathExists = async (target: string): Promise<boolean> => {
63
+ try {
64
+ await stat(target);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
70
+
71
+ const isFileMissingError = (error: unknown): boolean => {
72
+ const code = (error as { code?: unknown }).code;
73
+ return code === 'ENOENT' || code === 'ENOTDIR';
74
+ };
75
+
76
+ const readVarint = (buffer: Uint8Array, start: number, end: number): { next: number; value: number } => {
77
+ let value = 0;
78
+ let multiplier = 1;
79
+
80
+ for (let index = start; index < end; index += 1) {
81
+ const byte = buffer[index]!;
82
+ value += (byte & 0x7f) * multiplier;
83
+ if ((byte & 0x80) === 0) {
84
+ return { next: index + 1, value };
85
+ }
86
+ multiplier *= 0x80;
87
+ }
88
+
89
+ throw new Error('Unterminated protobuf varint');
90
+ };
91
+
92
+ const parseProtoFields = (buffer: Uint8Array, start = 0, end = buffer.length): ProtoField[] => {
93
+ const fields: ProtoField[] = [];
94
+ let index = start;
95
+
96
+ while (index < end) {
97
+ const key = readVarint(buffer, index, end);
98
+ index = key.next;
99
+ const fieldNumber = key.value >> 3;
100
+ const wireType = key.value & 7;
101
+ if (fieldNumber <= 0) {
102
+ throw new Error(`Invalid protobuf field number: ${fieldNumber}`);
103
+ }
104
+
105
+ if (wireType === 0) {
106
+ const fieldValue = readVarint(buffer, index, end);
107
+ index = fieldValue.next;
108
+ fields.push({ fieldNumber, value: fieldValue.value, wireType });
109
+ continue;
110
+ }
111
+
112
+ if (wireType === 1) {
113
+ index += 8;
114
+ fields.push({ fieldNumber, wireType });
115
+ continue;
116
+ }
117
+
118
+ if (wireType === 2) {
119
+ const length = readVarint(buffer, index, end);
120
+ index = length.next;
121
+ const next = index + length.value;
122
+ if (next > end) {
123
+ throw new Error('Invalid protobuf length-delimited field');
124
+ }
125
+ fields.push({ bytes: buffer.slice(index, next), fieldNumber, wireType });
126
+ index = next;
127
+ continue;
128
+ }
129
+
130
+ if (wireType === 5) {
131
+ index += 4;
132
+ fields.push({ fieldNumber, wireType });
133
+ continue;
134
+ }
135
+
136
+ throw new Error(`Unsupported protobuf wire type: ${wireType}`);
137
+ }
138
+
139
+ return fields;
140
+ };
141
+
142
+ const firstField = (fields: ProtoField[], fieldNumber: number): ProtoField | null =>
143
+ fields.find((field) => field.fieldNumber === fieldNumber) ?? null;
144
+
145
+ const fieldString = (fields: ProtoField[], fieldNumber: number): string | null => {
146
+ const field = firstField(fields, fieldNumber);
147
+ if (!field?.bytes) {
148
+ return null;
149
+ }
150
+
151
+ return decoder.decode(field.bytes);
152
+ };
153
+
154
+ const fieldNumberValue = (fields: ProtoField[], fieldNumber: number): number | null => {
155
+ return firstField(fields, fieldNumber)?.value ?? null;
156
+ };
157
+
158
+ const nestedFields = (field: ProtoField | null): ProtoField[] => {
159
+ if (!field?.bytes) {
160
+ return [];
161
+ }
162
+
163
+ return parseProtoFields(field.bytes);
164
+ };
165
+
166
+ const parseTimestampMs = (field: ProtoField | null): number | null => {
167
+ const fields = nestedFields(field);
168
+ const seconds = fieldNumberValue(fields, 1);
169
+ if (seconds === null) {
170
+ return null;
171
+ }
172
+
173
+ const nanos = fieldNumberValue(fields, 2) ?? 0;
174
+ return seconds * 1000 + Math.floor(nanos / 1_000_000);
175
+ };
176
+
177
+ const cleanTitle = (value: string | null, fallback: string): string => {
178
+ const title = value?.replace(/\s+/g, ' ').trim();
179
+ if (!title) {
180
+ return fallback;
181
+ }
182
+
183
+ return title.length > 180 ? `${title.slice(0, 177)}...` : title;
184
+ };
185
+
186
+ const decodeFileUri = (value: string): string => {
187
+ if (!value.startsWith('file://')) {
188
+ return value;
189
+ }
190
+
191
+ try {
192
+ return decodeURIComponent(new URL(value).pathname);
193
+ } catch {
194
+ return decodeURIComponent(value.slice('file://'.length));
195
+ }
196
+ };
197
+
198
+ const normalizeWorkspaceFolder = (value: string): string => {
199
+ const decoded = decodeFileUri(value.trim());
200
+ return decoded.replace(/\/+$/u, '') || decoded;
201
+ };
202
+
203
+ const workspaceFromFolder = (folderValue: string | null): WorkspaceInfo | null => {
204
+ if (!folderValue) {
205
+ return null;
206
+ }
207
+
208
+ const folder = normalizeWorkspaceFolder(folderValue);
209
+ if (!folder) {
210
+ return null;
211
+ }
212
+
213
+ return {
214
+ workspaceFolder: folder,
215
+ workspaceKey: `folder:${folder}`,
216
+ workspaceLabel: path.basename(folder) || folder,
217
+ workspaceUri: null,
218
+ };
219
+ };
220
+
221
+ const workspaceFromUri = (uri: string | null): WorkspaceInfo | null => {
222
+ if (!uri) {
223
+ return null;
224
+ }
225
+
226
+ const workspace = workspaceFromFolder(uri);
227
+ if (!workspace) {
228
+ return null;
229
+ }
230
+
231
+ return {
232
+ ...workspace,
233
+ workspaceUri: uri,
234
+ };
235
+ };
236
+
237
+ const parseWorkspaceInfo = (field: ProtoField | null): WorkspaceInfo | null => {
238
+ const fields = nestedFields(field);
239
+ const uri = fieldString(fields, 1) ?? fieldString(fields, 2);
240
+ return workspaceFromUri(uri);
241
+ };
242
+
243
+ const parseContextWorkspaceInfo = (field: ProtoField | null): WorkspaceInfo | null => {
244
+ const fields = nestedFields(field);
245
+ const directUri = fieldString(fields, 7);
246
+ if (directUri) {
247
+ return workspaceFromUri(directUri);
248
+ }
249
+
250
+ const nestedWorkspace = firstField(fields, 1);
251
+ return parseWorkspaceInfo(nestedWorkspace);
252
+ };
253
+
254
+ // Antigravity summary parsing is reverse-engineered from agyhub_summaries_proto.pb:
255
+ // entry field 1 = conversation id, entry field 2 = summary message. Inside that summary,
256
+ // field 1 = title, 2 = indexed item count, 3 = last-updated timestamp, 7 = created timestamp,
257
+ // 9 = workspace info, and 17 = context workspace info. parseWorkspaceInfo uses nested fields
258
+ // 1/2 for URI variants; parseContextWorkspaceInfo uses field 7 or nested workspace field 1.
259
+ const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): SummaryEntry | null => {
260
+ try {
261
+ const entryFields = nestedFields(entryField);
262
+ const conversationId = fieldString(entryFields, 1);
263
+ const summaryBytes = firstField(entryFields, 2);
264
+ if (!conversationId || !summaryBytes) {
265
+ return null;
266
+ }
267
+
268
+ const summaryFields = nestedFields(summaryBytes);
269
+ const workspace =
270
+ parseWorkspaceInfo(firstField(summaryFields, 9)) ??
271
+ parseContextWorkspaceInfo(firstField(summaryFields, 17)) ??
272
+ UNKNOWN_WORKSPACE;
273
+
274
+ return {
275
+ ...workspace,
276
+ conversationId,
277
+ createdAtMs: parseTimestampMs(firstField(summaryFields, 7)),
278
+ indexedItemCount: fieldNumberValue(summaryFields, 2),
279
+ lastUpdatedAtMs: parseTimestampMs(firstField(summaryFields, 3)),
280
+ summaryPath,
281
+ title: cleanTitle(fieldString(summaryFields, 1), conversationId),
282
+ };
283
+ } catch {
284
+ return null;
285
+ }
286
+ };
287
+
288
+ export const readAntigravitySummaryIndex = async (summaryPath: string): Promise<SummaryEntry[]> => {
289
+ if (!(await pathExists(summaryPath))) {
290
+ return [];
291
+ }
292
+
293
+ try {
294
+ const buffer = new Uint8Array(await Bun.file(summaryPath).arrayBuffer());
295
+ return parseProtoFields(buffer)
296
+ .filter((field) => field.fieldNumber === 1)
297
+ .map((field) => parseSummaryEntry(field, summaryPath))
298
+ .filter((entry): entry is SummaryEntry => entry !== null);
299
+ } catch {
300
+ return [];
301
+ }
302
+ };
303
+
304
+ const preferConversationFile = (
305
+ current: ConversationFile | undefined,
306
+ candidate: ConversationFile,
307
+ ): ConversationFile => {
308
+ if (!current) {
309
+ return candidate;
310
+ }
311
+
312
+ if (candidate.mtimeMs !== current.mtimeMs) {
313
+ return candidate.mtimeMs > current.mtimeMs ? candidate : current;
314
+ }
315
+
316
+ return candidate.bytes > current.bytes ? candidate : current;
317
+ };
318
+
319
+ const readConversationFileCandidate = async (
320
+ root: string,
321
+ conversationDir: string,
322
+ entry: { isFile: () => boolean; name: string },
323
+ ): Promise<{ conversationId: string; file: ConversationFile } | null> => {
324
+ if (!entry.isFile() || !entry.name.endsWith('.pb')) {
325
+ return null;
326
+ }
327
+
328
+ const conversationId = entry.name.slice(0, -'.pb'.length);
329
+ const filePath = path.join(conversationDir, entry.name);
330
+ try {
331
+ const info = await stat(filePath);
332
+ return {
333
+ conversationId,
334
+ file: {
335
+ bytes: info.size,
336
+ mtimeMs: info.mtimeMs,
337
+ path: filePath,
338
+ root,
339
+ },
340
+ };
341
+ } catch (error) {
342
+ if (isFileMissingError(error)) {
343
+ return null;
344
+ }
345
+
346
+ throw error;
347
+ }
348
+ };
349
+
350
+ const readConversationFiles = async (roots: string[]): Promise<Map<string, ConversationFile>> => {
351
+ const files = new Map<string, ConversationFile>();
352
+ for (const root of roots) {
353
+ const conversationDir = getAntigravityConversationDir(root);
354
+ let entries: Array<{ isFile: () => boolean; name: string }> = [];
355
+ try {
356
+ entries = await readdir(conversationDir, { withFileTypes: true });
357
+ } catch {
358
+ continue;
359
+ }
360
+
361
+ for (const entry of entries) {
362
+ const candidate = await readConversationFileCandidate(root, conversationDir, entry);
363
+ if (candidate) {
364
+ files.set(
365
+ candidate.conversationId,
366
+ preferConversationFile(files.get(candidate.conversationId), candidate.file),
367
+ );
368
+ }
369
+ }
370
+ }
371
+
372
+ return files;
373
+ };
374
+
375
+ const readArtifactMetadata = async (
376
+ markdownPath: string,
377
+ ): Promise<{ artifactType: string | null; summary: string | null; updatedAtMs: number | null }> => {
378
+ try {
379
+ const data = (await Bun.file(`${markdownPath}.metadata.json`).json()) as {
380
+ artifactType?: unknown;
381
+ summary?: unknown;
382
+ updatedAt?: unknown;
383
+ };
384
+ const updatedAt = typeof data.updatedAt === 'string' ? Date.parse(data.updatedAt) : Number.NaN;
385
+ return {
386
+ artifactType: typeof data.artifactType === 'string' ? data.artifactType : null,
387
+ summary: typeof data.summary === 'string' ? data.summary : null,
388
+ updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : null,
389
+ };
390
+ } catch {
391
+ return { artifactType: null, summary: null, updatedAtMs: null };
392
+ }
393
+ };
394
+
395
+ const readArtifactCandidate = async (
396
+ root: string,
397
+ artifactPath: string,
398
+ fileName: string,
399
+ ): Promise<AntigravityArtifact | null> => {
400
+ try {
401
+ const [info, metadata] = await Promise.all([stat(artifactPath), readArtifactMetadata(artifactPath)]);
402
+ return {
403
+ artifactType: metadata.artifactType,
404
+ bytes: info.size,
405
+ name: fileName,
406
+ path: artifactPath,
407
+ sourceRoot: root,
408
+ summary: metadata.summary,
409
+ updatedAtMs: metadata.updatedAtMs ?? info.mtimeMs,
410
+ };
411
+ } catch (error) {
412
+ if (isFileMissingError(error)) {
413
+ return null;
414
+ }
415
+
416
+ throw error;
417
+ }
418
+ };
419
+
420
+ const readArtifactsForRoot = async (root: string): Promise<Map<string, AntigravityArtifact[]>> => {
421
+ const brainDir = getAntigravityBrainDir(root);
422
+ let entries: Array<{ isDirectory: () => boolean; name: string }> = [];
423
+ try {
424
+ entries = await readdir(brainDir, { withFileTypes: true });
425
+ } catch {
426
+ return new Map();
427
+ }
428
+
429
+ const artifactsByConversation = new Map<string, AntigravityArtifact[]>();
430
+ for (const entry of entries) {
431
+ if (!entry.isDirectory()) {
432
+ continue;
433
+ }
434
+
435
+ const artifactDir = path.join(brainDir, entry.name);
436
+ const files = await readdir(artifactDir, { withFileTypes: true }).catch(() => []);
437
+ for (const file of files) {
438
+ if (!file.isFile() || !file.name.endsWith('.md')) {
439
+ continue;
440
+ }
441
+
442
+ const artifactPath = path.join(artifactDir, file.name);
443
+ const artifact = await readArtifactCandidate(root, artifactPath, file.name);
444
+ if (!artifact) {
445
+ continue;
446
+ }
447
+ const list = artifactsByConversation.get(entry.name) ?? [];
448
+ list.push(artifact);
449
+ artifactsByConversation.set(entry.name, list);
450
+ }
451
+ }
452
+
453
+ return artifactsByConversation;
454
+ };
455
+
456
+ const mergeArtifactMaps = async (roots: string[]): Promise<Map<string, AntigravityArtifact[]>> => {
457
+ const merged = new Map<string, AntigravityArtifact[]>();
458
+ for (const root of roots) {
459
+ const artifacts = await readArtifactsForRoot(root);
460
+ for (const [conversationId, list] of artifacts) {
461
+ const existing = merged.get(conversationId) ?? [];
462
+ const byName = new Map(existing.map((artifact) => [artifact.name, artifact]));
463
+ for (const artifact of list) {
464
+ if (!byName.has(artifact.name)) {
465
+ byName.set(artifact.name, artifact);
466
+ }
467
+ }
468
+ merged.set(
469
+ conversationId,
470
+ [...byName.values()].sort(
471
+ (a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0) || a.name.localeCompare(b.name),
472
+ ),
473
+ );
474
+ }
475
+ }
476
+
477
+ return merged;
478
+ };
479
+
480
+ const countJsonlEntries = async (filePath: string): Promise<number> => {
481
+ try {
482
+ const text = await Bun.file(filePath).text();
483
+ return text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length;
484
+ } catch {
485
+ return 0;
486
+ }
487
+ };
488
+
489
+ const preferTranscriptFile = (current: TranscriptFile | undefined, candidate: TranscriptFile): TranscriptFile => {
490
+ if (!current) {
491
+ return candidate;
492
+ }
493
+
494
+ if (candidate.mtimeMs !== current.mtimeMs) {
495
+ return candidate.mtimeMs > current.mtimeMs ? candidate : current;
496
+ }
497
+
498
+ if (candidate.source !== current.source) {
499
+ return candidate.source === 'overview' ? candidate : current;
500
+ }
501
+
502
+ return candidate.entryCount > current.entryCount ? candidate : current;
503
+ };
504
+
505
+ const readTranscriptFilesForRoot = async (root: string): Promise<Map<string, TranscriptFile>> => {
506
+ const brainDir = getAntigravityBrainDir(root);
507
+ let entries: Array<{ isDirectory: () => boolean; name: string }> = [];
508
+ try {
509
+ entries = await readdir(brainDir, { withFileTypes: true });
510
+ } catch {
511
+ return new Map();
512
+ }
513
+
514
+ const transcripts = new Map<string, TranscriptFile>();
515
+ for (const entry of entries) {
516
+ if (!entry.isDirectory()) {
517
+ continue;
518
+ }
519
+
520
+ const logsDir = path.join(brainDir, entry.name, '.system_generated', 'logs');
521
+ for (const candidate of [
522
+ { name: 'overview.txt', source: 'overview' as const },
523
+ { name: 'transcript.jsonl', source: 'transcript' as const },
524
+ ]) {
525
+ const transcriptPath = path.join(logsDir, candidate.name);
526
+ try {
527
+ const info = await stat(transcriptPath);
528
+ if (!info.isFile()) {
529
+ continue;
530
+ }
531
+
532
+ transcripts.set(
533
+ entry.name,
534
+ preferTranscriptFile(transcripts.get(entry.name), {
535
+ bytes: info.size,
536
+ entryCount: await countJsonlEntries(transcriptPath),
537
+ mtimeMs: info.mtimeMs,
538
+ path: transcriptPath,
539
+ root,
540
+ source: candidate.source,
541
+ }),
542
+ );
543
+ } catch {}
544
+ }
545
+ }
546
+
547
+ return transcripts;
548
+ };
549
+
550
+ const mergeTranscriptMaps = async (roots: string[]): Promise<Map<string, TranscriptFile>> => {
551
+ const merged = new Map<string, TranscriptFile>();
552
+ for (const root of roots) {
553
+ const transcripts = await readTranscriptFilesForRoot(root);
554
+ for (const [conversationId, transcript] of transcripts) {
555
+ merged.set(conversationId, preferTranscriptFile(merged.get(conversationId), transcript));
556
+ }
557
+ }
558
+
559
+ return merged;
560
+ };
561
+
562
+ const readSummaryEntries = async (roots: string[]): Promise<Map<string, SummaryEntry>> => {
563
+ const summaries = new Map<string, SummaryEntry>();
564
+ for (const root of roots) {
565
+ for (const entry of await readAntigravitySummaryIndex(getAntigravitySummaryIndexPath(root))) {
566
+ const existing = summaries.get(entry.conversationId);
567
+ if (!existing || (entry.lastUpdatedAtMs ?? 0) > (existing.lastUpdatedAtMs ?? 0)) {
568
+ summaries.set(entry.conversationId, entry);
569
+ }
570
+ }
571
+ }
572
+
573
+ return summaries;
574
+ };
575
+
576
+ const maxArtifactUpdatedAt = (artifacts: AntigravityArtifact[]): number | null => {
577
+ const value = Math.max(0, ...artifacts.map((artifact) => artifact.updatedAtMs ?? 0));
578
+ return value > 0 ? value : null;
579
+ };
580
+
581
+ const resolveConversationSourceRoot = (
582
+ file: ConversationFile | undefined,
583
+ transcript: TranscriptFile | undefined,
584
+ artifacts: AntigravityArtifact[],
585
+ ) => {
586
+ return file?.root ?? transcript?.root ?? artifacts[0]?.sourceRoot ?? null;
587
+ };
588
+
589
+ const resolveConversationWorkspace = (
590
+ summary: SummaryEntry | undefined,
591
+ file: ConversationFile | undefined,
592
+ transcript: TranscriptFile | undefined,
593
+ artifacts: AntigravityArtifact[],
594
+ ): WorkspaceInfo => {
595
+ return (
596
+ summary ?? workspaceFromFolder(resolveConversationSourceRoot(file, transcript, artifacts)) ?? UNKNOWN_WORKSPACE
597
+ );
598
+ };
599
+
600
+ const resolveConversationTranscriptSource = (
601
+ file: ConversationFile | undefined,
602
+ transcript: TranscriptFile | undefined,
603
+ ) => {
604
+ return transcript?.source ?? (file?.path ? 'safe-storage' : null);
605
+ };
606
+
607
+ const resolveConversationLastUpdatedAt = (
608
+ artifacts: AntigravityArtifact[],
609
+ file: ConversationFile | undefined,
610
+ summary: SummaryEntry | undefined,
611
+ transcript: TranscriptFile | undefined,
612
+ ) => {
613
+ return summary?.lastUpdatedAtMs ?? transcript?.mtimeMs ?? file?.mtimeMs ?? maxArtifactUpdatedAt(artifacts);
614
+ };
615
+
616
+ const toConversation = (
617
+ conversationId: string,
618
+ summary: SummaryEntry | undefined,
619
+ file: ConversationFile | undefined,
620
+ artifacts: AntigravityArtifact[],
621
+ transcript: TranscriptFile | undefined,
622
+ ): AntigravityConversation => {
623
+ const fallbackTitle = artifacts[0]?.summary ?? conversationId;
624
+ const artifactBytes = artifacts.reduce((total, artifact) => total + artifact.bytes, 0);
625
+ const workspace = resolveConversationWorkspace(summary, file, transcript, artifacts);
626
+ const lastUpdatedAtMs = resolveConversationLastUpdatedAt(artifacts, file, summary, transcript);
627
+ const sourceRoot = resolveConversationSourceRoot(file, transcript, artifacts);
628
+
629
+ return {
630
+ artifactBytes,
631
+ artifactCount: artifacts.length,
632
+ artifacts,
633
+ conversationBytes: file?.bytes ?? 0,
634
+ conversationId,
635
+ conversationMtimeMs: file?.mtimeMs ?? null,
636
+ conversationPath: file?.path ?? null,
637
+ createdAtMs: summary?.createdAtMs ?? null,
638
+ indexedItemCount: summary?.indexedItemCount ?? null,
639
+ lastUpdatedAtMs,
640
+ sourceRoot,
641
+ summaryPath: summary?.summaryPath ?? null,
642
+ title: summary?.title ?? cleanTitle(fallbackTitle, conversationId),
643
+ transcriptBytes: transcript?.bytes ?? 0,
644
+ transcriptEntryCount: transcript?.entryCount ?? 0,
645
+ transcriptPath: transcript?.path ?? null,
646
+ transcriptSource: resolveConversationTranscriptSource(file, transcript),
647
+ workspaceFolder: workspace.workspaceFolder,
648
+ workspaceKey: workspace.workspaceKey,
649
+ workspaceLabel: workspace.workspaceLabel,
650
+ workspaceUri: workspace.workspaceUri,
651
+ };
652
+ };
653
+
654
+ export const listAntigravityConversations = async (
655
+ roots = resolveAntigravityRoots(),
656
+ ): Promise<AntigravityConversation[]> => {
657
+ const [summaries, conversationFiles, artifacts, transcripts] = await Promise.all([
658
+ readSummaryEntries(roots),
659
+ readConversationFiles(roots),
660
+ mergeArtifactMaps(roots),
661
+ mergeTranscriptMaps(roots),
662
+ ]);
663
+
664
+ const ids = new Set<string>([
665
+ ...summaries.keys(),
666
+ ...conversationFiles.keys(),
667
+ ...artifacts.keys(),
668
+ ...transcripts.keys(),
669
+ ]);
670
+ return [...ids]
671
+ .map((conversationId) =>
672
+ toConversation(
673
+ conversationId,
674
+ summaries.get(conversationId),
675
+ conversationFiles.get(conversationId),
676
+ artifacts.get(conversationId) ?? [],
677
+ transcripts.get(conversationId),
678
+ ),
679
+ )
680
+ .sort((a, b) => (b.lastUpdatedAtMs ?? 0) - (a.lastUpdatedAtMs ?? 0) || a.title.localeCompare(b.title));
681
+ };
682
+
683
+ export const groupAntigravityConversations = (
684
+ conversations: AntigravityConversation[],
685
+ ): AntigravityWorkspaceGroup[] => {
686
+ const groups = new Map<string, AntigravityWorkspaceGroup>();
687
+ for (const conversation of conversations) {
688
+ const current = groups.get(conversation.workspaceKey) ?? {
689
+ artifactCount: 0,
690
+ conversationBytes: 0,
691
+ conversationCount: 0,
692
+ key: conversation.workspaceKey,
693
+ label: conversation.workspaceLabel,
694
+ lastActiveMs: 0,
695
+ transcriptCount: 0,
696
+ uri: conversation.workspaceUri,
697
+ };
698
+ current.artifactCount += conversation.artifactCount;
699
+ current.conversationBytes += conversation.conversationBytes;
700
+ current.conversationCount += 1;
701
+ current.lastActiveMs = Math.max(current.lastActiveMs, conversation.lastUpdatedAtMs ?? 0);
702
+ current.transcriptCount += conversation.transcriptEntryCount > 0 ? 1 : 0;
703
+ groups.set(conversation.workspaceKey, current);
704
+ }
705
+
706
+ return [...groups.values()].sort((a, b) => b.lastActiveMs - a.lastActiveMs || a.label.localeCompare(b.label));
707
+ };
708
+
709
+ export const listAntigravityWorkspaceGroups = async (
710
+ roots = resolveAntigravityRoots(),
711
+ ): Promise<AntigravityWorkspaceGroup[]> => {
712
+ return groupAntigravityConversations(await listAntigravityConversations(roots));
713
+ };
714
+
715
+ export const listAntigravityConversationsForGroup = async (
716
+ workspaceKey: string,
717
+ roots = resolveAntigravityRoots(),
718
+ ): Promise<AntigravityConversation[]> => {
719
+ return (await listAntigravityConversations(roots)).filter(
720
+ (conversation) => conversation.workspaceKey === workspaceKey,
721
+ );
722
+ };
723
+
724
+ export const renderAntigravityArtifactsMarkdown = async (
725
+ conversation: AntigravityConversation,
726
+ ): Promise<string | null> => {
727
+ if (conversation.artifacts.length === 0) {
728
+ return null;
729
+ }
730
+
731
+ const parts = [
732
+ `# ${conversation.title}`,
733
+ '',
734
+ '- exported_from: `antigravity_brain_artifacts`',
735
+ `- conversation_id: \`${conversation.conversationId}\``,
736
+ conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
737
+ '',
738
+ ].filter(Boolean);
739
+
740
+ for (const artifact of conversation.artifacts) {
741
+ const body = await Bun.file(artifact.path).text();
742
+ parts.push(`## ${artifact.name}`, '');
743
+ if (artifact.summary) {
744
+ parts.push(`_${artifact.summary}_`, '');
745
+ }
746
+ parts.push(body.trimEnd(), '');
747
+ }
748
+
749
+ return `${parts.join('\n').trimEnd()}\n`;
750
+ };
751
+
752
+ type AntigravityLogEntry = {
753
+ content?: unknown;
754
+ created_at?: unknown;
755
+ source?: unknown;
756
+ status?: unknown;
757
+ step_index?: unknown;
758
+ thinking?: unknown;
759
+ tool_calls?: unknown;
760
+ type?: unknown;
761
+ };
762
+
763
+ const parseLogEntries = (content: string): AntigravityLogEntry[] => {
764
+ return content
765
+ .split(/\r?\n/u)
766
+ .map((line) => line.trim())
767
+ .filter(Boolean)
768
+ .map((line) => {
769
+ try {
770
+ return JSON.parse(line) as AntigravityLogEntry;
771
+ } catch {
772
+ return null;
773
+ }
774
+ })
775
+ .filter((entry): entry is AntigravityLogEntry => entry !== null);
776
+ };
777
+
778
+ const getString = (value: unknown): string | null => (typeof value === 'string' ? value : null);
779
+
780
+ const stripTaggedBlock = (content: string, tag: string): string => {
781
+ return content.replace(new RegExp(`<${tag}>[\\s\\S]*?<\\/${tag}>`, 'gu'), '').trim();
782
+ };
783
+
784
+ const extractTaggedBlock = (content: string, tag: string): string | null => {
785
+ const match = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*<\\/${tag}>`, 'u').exec(content);
786
+ return match?.[1]?.trim() || null;
787
+ };
788
+
789
+ const cleanLogContent = (entry: AntigravityLogEntry): string => {
790
+ const content = getString(entry.content);
791
+ if (!content) {
792
+ return '';
793
+ }
794
+
795
+ const userRequest = extractTaggedBlock(content, 'USER_REQUEST');
796
+ if (userRequest) {
797
+ return userRequest;
798
+ }
799
+
800
+ return ['ADDITIONAL_METADATA', 'USER_SETTINGS_CHANGE']
801
+ .reduce((current, tag) => stripTaggedBlock(current, tag), content)
802
+ .replace(/<\/?USER_REQUEST>/gu, '')
803
+ .trim();
804
+ };
805
+
806
+ const logEntryHeading = (entry: AntigravityLogEntry): string => {
807
+ const source = getString(entry.source);
808
+ const type = getString(entry.type);
809
+ if (source?.startsWith('USER')) {
810
+ return 'User';
811
+ }
812
+
813
+ if (source === 'MODEL') {
814
+ return 'Assistant';
815
+ }
816
+
817
+ if (source === 'SYSTEM') {
818
+ return 'System';
819
+ }
820
+
821
+ return type ? `Tool: ${type}` : 'Event';
822
+ };
823
+
824
+ const renderToolCalls = (toolCalls: unknown): string[] => {
825
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
826
+ return [];
827
+ }
828
+
829
+ const parts = ['### Tool Calls', ''];
830
+ for (const call of toolCalls) {
831
+ if (!call || typeof call !== 'object') {
832
+ continue;
833
+ }
834
+
835
+ const { args, name } = call as { args?: unknown; name?: unknown };
836
+ parts.push(`- \`${typeof name === 'string' ? name : 'unknown'}\``);
837
+ if (args !== undefined) {
838
+ parts.push('', '```json', JSON.stringify(args, null, 2), '```', '');
839
+ }
840
+ }
841
+
842
+ return parts;
843
+ };
844
+
845
+ const renderLogEntry = (entry: AntigravityLogEntry): string[] => {
846
+ const heading = logEntryHeading(entry);
847
+ const timestamp = getString(entry.created_at);
848
+ const content = cleanLogContent(entry);
849
+ const thinking = getString(entry.thinking);
850
+ const parts = [`## ${heading}`, ''];
851
+
852
+ if (timestamp) {
853
+ parts.push(`_Timestamp: ${timestamp}_`, '');
854
+ }
855
+
856
+ if (thinking) {
857
+ parts.push('### Thinking', '', thinking.trim(), '');
858
+ }
859
+
860
+ if (content) {
861
+ parts.push(content, '');
862
+ }
863
+
864
+ parts.push(...renderToolCalls(entry.tool_calls));
865
+ return parts;
866
+ };
867
+
868
+ const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConversation): Promise<string | null> => {
869
+ if (!conversation.transcriptPath || !conversation.transcriptSource) {
870
+ return null;
871
+ }
872
+
873
+ const entries = parseLogEntries(await Bun.file(conversation.transcriptPath).text());
874
+ if (entries.length === 0) {
875
+ return null;
876
+ }
877
+
878
+ const exportedFrom =
879
+ conversation.transcriptSource === 'overview'
880
+ ? 'antigravity_overview_transcript'
881
+ : 'antigravity_jsonl_transcript';
882
+ const parts = [
883
+ `# ${conversation.title}`,
884
+ '',
885
+ `- exported_from: \`${exportedFrom}\``,
886
+ `- conversation_id: \`${conversation.conversationId}\``,
887
+ conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
888
+ '',
889
+ ].filter(Boolean);
890
+
891
+ for (const entry of entries) {
892
+ parts.push(...renderLogEntry(entry));
893
+ }
894
+
895
+ return `${parts.join('\n').trimEnd()}\n`;
896
+ };
897
+
898
+ const renderDecryptedSafeStorageMarkdown = (conversation: AntigravityConversation, content: string): string | null => {
899
+ const trimmed = content.trim();
900
+ if (!trimmed) {
901
+ return null;
902
+ }
903
+
904
+ return [
905
+ `# ${conversation.title}`,
906
+ '',
907
+ '- exported_from: `antigravity_safe_storage_payload`',
908
+ `- conversation_id: \`${conversation.conversationId}\``,
909
+ conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
910
+ '',
911
+ trimmed,
912
+ '',
913
+ ]
914
+ .filter(Boolean)
915
+ .join('\n');
916
+ };
917
+
918
+ export const renderAntigravityConversationMarkdown = async (
919
+ conversation: AntigravityConversation,
920
+ options: { keychainSecret?: string | null } = {},
921
+ ): Promise<string | null> => {
922
+ const transcript = await renderAntigravityTranscriptMarkdown(conversation);
923
+ if (transcript) {
924
+ return transcript;
925
+ }
926
+
927
+ if (options.keychainSecret && conversation.conversationPath) {
928
+ const encrypted = Buffer.from(await Bun.file(conversation.conversationPath).arrayBuffer());
929
+ const decrypted = decryptAntigravitySafeStoragePayload(encrypted, options.keychainSecret);
930
+ if (decrypted) {
931
+ return renderDecryptedSafeStorageMarkdown(conversation, decrypted);
932
+ }
933
+ }
934
+
935
+ return null;
936
+ };