spiracha 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
package/package.json CHANGED
@@ -11,37 +11,37 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@tailwindcss/typography": "0.5.20",
14
- "@tailwindcss/vite": "4.3.1",
15
- "@tanstack/devtools-vite": "0.7.0",
14
+ "@tailwindcss/vite": "4.3.2",
15
+ "@tanstack/devtools-vite": "0.8.1",
16
16
  "@tanstack/match-sorter-utils": "8.19.4",
17
- "@tanstack/react-devtools": "0.10.5",
18
- "@tanstack/react-query": "5.101.0",
19
- "@tanstack/react-query-devtools": "5.101.0",
20
- "@tanstack/react-router": "1.170.15",
17
+ "@tanstack/react-devtools": "0.10.8",
18
+ "@tanstack/react-query": "5.101.2",
19
+ "@tanstack/react-query-devtools": "5.101.2",
20
+ "@tanstack/react-router": "1.170.18",
21
21
  "@tanstack/react-router-devtools": "1.167.0",
22
22
  "@tanstack/react-router-ssr-query": "1.167.1",
23
- "@tanstack/react-start": "1.168.25",
23
+ "@tanstack/react-start": "1.168.28",
24
24
  "@tanstack/react-table": "8.21.3",
25
- "@tanstack/react-virtual": "3.14.2",
26
- "@tanstack/router-plugin": "1.168.18",
27
- "@vitejs/plugin-react": "6.0.2",
25
+ "@tanstack/react-virtual": "3.14.6",
26
+ "@tanstack/router-plugin": "1.168.20",
27
+ "@vitejs/plugin-react": "6.0.3",
28
28
  "class-variance-authority": "0.7.1",
29
29
  "clsx": "2.1.1",
30
- "lucide-react": "1.18.0",
31
- "radix-ui": "1.5.0",
30
+ "lucide-react": "1.24.0",
31
+ "radix-ui": "1.6.2",
32
32
  "react": "19.2.7",
33
33
  "react-dom": "19.2.7",
34
34
  "tailwind-merge": "3.6.0",
35
- "tailwindcss": "4.3.1",
35
+ "tailwindcss": "4.3.2",
36
36
  "tw-animate-css": "1.4.0",
37
- "vite": "8.0.16",
37
+ "vite": "8.1.4",
38
38
  "zod": "4.4.3"
39
39
  },
40
- "description": "Browse and expose local Codex, Claude Code, Kiro, Qoder, Cursor, Antigravity, and OpenCode conversation history through a local UI and stable data API.",
40
+ "description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, and OpenCode conversation history through a local UI and stable data API.",
41
41
  "devDependencies": {
42
42
  "@types/bun": "^1.3.14",
43
- "@types/node": "^25.9.3",
44
- "typescript": "^6.0.3"
43
+ "@types/node": "^26.1.1",
44
+ "typescript": "^7.0.2"
45
45
  },
46
46
  "engines": {
47
47
  "bun": ">=1.3.14"
@@ -81,6 +81,7 @@
81
81
  "keywords": [
82
82
  "codex",
83
83
  "claude",
84
+ "grok",
84
85
  "kiro",
85
86
  "qoder",
86
87
  "cursor",
@@ -112,7 +113,7 @@
112
113
  "ui:preview": "bun run --cwd apps/ui preview"
113
114
  },
114
115
  "type": "module",
115
- "version": "2.0.0",
116
+ "version": "2.1.0",
116
117
  "workspaces": [
117
118
  "apps/*"
118
119
  ]
package/src/client.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import {
2
+ deleteConversation as deleteLocalConversation,
3
+ deleteConversations as deleteLocalConversations,
2
4
  getConversation as getLocalConversation,
3
5
  listConversationSources as listLocalConversationSources,
4
6
  listConversationsForPath as listLocalConversationsForPath,
@@ -11,10 +13,17 @@ import type {
11
13
  ConversationMessageSelector,
12
14
  ConversationPage,
13
15
  ConversationSourceInfo,
16
+ ConversationZipDownload,
17
+ DeleteConversationOptions,
18
+ DeleteConversationResult,
19
+ DeleteConversationsOptions,
20
+ DeleteConversationsResult,
21
+ ExportConversationsZipOptions,
14
22
  GetConversationOptions,
15
23
  ListConversationsForPathOptions,
16
24
  ResolvedConversationRef,
17
25
  } from './lib/conversation-data/types';
26
+ import { createConversationMarkdownZip } from './lib/conversation-zip-export';
18
27
 
19
28
  export type {
20
29
  ConversationDataLocations,
@@ -28,6 +37,12 @@ export type {
28
37
  ConversationPathMatch,
29
38
  ConversationSource,
30
39
  ConversationSourceInfo,
40
+ ConversationZipDownload,
41
+ DeleteConversationOptions,
42
+ DeleteConversationResult,
43
+ DeleteConversationsOptions,
44
+ DeleteConversationsResult,
45
+ ExportConversationsZipOptions,
31
46
  GetConversationOptions,
32
47
  ListConversationsForPathOptions,
33
48
  ResolvedConversationRef,
@@ -70,7 +85,10 @@ export type CreateConversationClientOptions = HttpConversationClientOptions | Lo
70
85
  export type ExportConversationMarkdownOptions = GetConversationOptions;
71
86
 
72
87
  export type ConversationClient = {
88
+ deleteConversation: (options: DeleteConversationOptions) => Promise<DeleteConversationResult | null>;
89
+ deleteConversations: (options: DeleteConversationsOptions) => Promise<DeleteConversationsResult | null>;
73
90
  exportConversationMarkdown: (options: ExportConversationMarkdownOptions) => Promise<string | null>;
91
+ exportConversationsZip: (options: ExportConversationsZipOptions) => Promise<ConversationZipDownload | null>;
74
92
  getConversation: (options: GetConversationOptions) => Promise<ConversationDetail | null>;
75
93
  listConversations: (options: ListConversationsForPathOptions) => Promise<ConversationPage>;
76
94
  listSources: () => Promise<ConversationSourceInfo[]>;
@@ -198,6 +216,34 @@ const fetchTextOrNull = async (url: URL, init?: RequestInit): Promise<string | n
198
216
  return response.text();
199
217
  };
200
218
 
219
+ const fileNameFromContentDisposition = (contentDisposition: string | null, fallback: string) => {
220
+ const encodedMatch = contentDisposition?.match(/filename\*=UTF-8''([^;]+)/iu);
221
+ if (encodedMatch?.[1]) {
222
+ try {
223
+ return decodeURIComponent(encodedMatch[1]);
224
+ } catch {
225
+ return fallback;
226
+ }
227
+ }
228
+
229
+ const quotedMatch = contentDisposition?.match(/filename="([^"]+)"/iu);
230
+ return quotedMatch?.[1] || fallback;
231
+ };
232
+
233
+ const fetchZipOrNull = async (url: URL, init?: RequestInit): Promise<ConversationZipDownload | null> => {
234
+ const response = await fetchResponse(url, init);
235
+ if (response.status === 404) {
236
+ return null;
237
+ }
238
+
239
+ await assertOkResponse(response);
240
+ return {
241
+ blob: await response.blob(),
242
+ fileName: fileNameFromContentDisposition(response.headers.get('Content-Disposition'), 'conversations.zip'),
243
+ mimeType: 'application/zip',
244
+ };
245
+ };
246
+
201
247
  const requireData = <T>(envelope: HttpEnvelope<T>, label: string): T => {
202
248
  if (envelope.data === undefined) {
203
249
  throw new SpirachaClientError(`Spiracha API response did not include ${label}.`);
@@ -229,11 +275,62 @@ const rejectHttpLocations = (locations: ConversationDataLocations | undefined):
229
275
  }
230
276
  };
231
277
 
278
+ const buildBatchBody = ({ ids, messageSelector, outputFormat, source }: ExportConversationsZipOptions) => ({
279
+ ids,
280
+ message_selector: messageSelector,
281
+ output_format: outputFormat,
282
+ source,
283
+ });
284
+
285
+ const exportLocalConversationsZip = async (
286
+ exportOptions: ExportConversationsZipOptions,
287
+ locations: ConversationDataLocations | undefined,
288
+ ) => {
289
+ const options = withDefaultLocations(exportOptions, locations);
290
+ const conversations = await Promise.all(
291
+ options.ids.map((id) =>
292
+ getLocalConversation({
293
+ id,
294
+ locations: options.locations,
295
+ messageSelector: options.messageSelector ?? 'all',
296
+ source: options.source,
297
+ }),
298
+ ),
299
+ );
300
+
301
+ if (conversations.some((conversation) => conversation === null)) {
302
+ return null;
303
+ }
304
+
305
+ return createConversationMarkdownZip({
306
+ entries: conversations.map((conversation, index) => {
307
+ const resolvedConversation = conversation!;
308
+ return {
309
+ fallbackBaseName: `${options.source}-${options.ids[index]}`,
310
+ markdown: renderLocalConversationMarkdown(resolvedConversation, {
311
+ messageSelector: options.messageSelector ?? 'all',
312
+ }),
313
+ title: resolvedConversation.title,
314
+ };
315
+ }),
316
+ fileBaseName: `${options.source}-conversations-${options.ids.length}`,
317
+ });
318
+ };
319
+
232
320
  const makeLocalClient = (options: LocalConversationClientOptions): ConversationClient => ({
321
+ deleteConversation: (deleteOptions) =>
322
+ deleteLocalConversation(withDefaultLocations(deleteOptions, options.locations)),
323
+ deleteConversations: (deleteOptions) =>
324
+ deleteLocalConversations(withDefaultLocations(deleteOptions, options.locations)),
233
325
  exportConversationMarkdown: async (getOptions) => {
234
326
  const conversation = await getLocalConversation(withDefaultLocations(getOptions, options.locations));
235
- return conversation ? renderLocalConversationMarkdown(conversation) : null;
327
+ return conversation
328
+ ? renderLocalConversationMarkdown(conversation, {
329
+ messageSelector: getOptions.messageSelector,
330
+ })
331
+ : null;
236
332
  },
333
+ exportConversationsZip: (exportOptions) => exportLocalConversationsZip(exportOptions, options.locations),
237
334
  getConversation: (getOptions) => getLocalConversation(withDefaultLocations(getOptions, options.locations)),
238
335
  listConversations: (listOptions) =>
239
336
  listLocalConversationsForPath(withDefaultLocations(listOptions, options.locations)),
@@ -245,6 +342,34 @@ const makeHttpClient = (options: HttpConversationClientOptions): ConversationCli
245
342
  const baseUrl = normalizeBaseUrl(options.baseUrl);
246
343
 
247
344
  return {
345
+ deleteConversation: async (deleteOptions) => {
346
+ rejectHttpLocations(deleteOptions.locations);
347
+ const { id, source } = deleteOptions;
348
+ const url = makeHttpUrl(baseUrl, `/api/v1/conversations/${source}/${encodeURIComponent(id)}`);
349
+ const envelope = await fetchJsonOrNull<DeleteConversationResult>(url, { method: 'DELETE' });
350
+ if (!envelope) {
351
+ return null;
352
+ }
353
+ return requireData(envelope, 'a delete result');
354
+ },
355
+ deleteConversations: async (deleteOptions) => {
356
+ rejectHttpLocations(deleteOptions.locations);
357
+ const envelope = await fetchJsonOrNull<DeleteConversationsResult>(
358
+ makeHttpUrl(baseUrl, '/api/v1/conversations/delete'),
359
+ {
360
+ body: JSON.stringify({
361
+ ids: deleteOptions.ids,
362
+ source: deleteOptions.source,
363
+ }),
364
+ headers: { 'Content-Type': 'application/json' },
365
+ method: 'POST',
366
+ },
367
+ );
368
+ if (!envelope) {
369
+ return null;
370
+ }
371
+ return requireData(envelope, 'a delete result');
372
+ },
248
373
  exportConversationMarkdown: async (getOptions) => {
249
374
  rejectHttpLocations(getOptions.locations);
250
375
  const { id, messageSelector, source } = getOptions;
@@ -252,6 +377,14 @@ const makeHttpClient = (options: HttpConversationClientOptions): ConversationCli
252
377
  appendMessageSelector(url, messageSelector);
253
378
  return fetchTextOrNull(url);
254
379
  },
380
+ exportConversationsZip: async (exportOptions) => {
381
+ rejectHttpLocations(exportOptions.locations);
382
+ return fetchZipOrNull(makeHttpUrl(baseUrl, '/api/v1/conversations/export'), {
383
+ body: JSON.stringify(buildBatchBody(exportOptions)),
384
+ headers: { 'Content-Type': 'application/json' },
385
+ method: 'POST',
386
+ });
387
+ },
255
388
  getConversation: async (getOptions) => {
256
389
  rejectHttpLocations(getOptions.locations);
257
390
  const { id, messageSelector, source } = getOptions;
@@ -1,4 +1,4 @@
1
- import { readdir, stat } from 'node:fs/promises';
1
+ import { readdir, rm, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  type AntigravityArtifact,
@@ -11,6 +11,7 @@ import {
11
11
  resolveAntigravityRoots,
12
12
  } from './antigravity-exporter-types';
13
13
  import { decryptAntigravitySafeStoragePayload } from './antigravity-keychain';
14
+ import { resolveAntigravityProjectNames } from './antigravity-projects';
14
15
  import { mapWithConcurrency } from './concurrency';
15
16
 
16
17
  type ProtoField = {
@@ -25,6 +26,7 @@ type SummaryEntry = {
25
26
  createdAtMs: number | null;
26
27
  indexedItemCount: number | null;
27
28
  lastUpdatedAtMs: number | null;
29
+ projectId: string | null;
28
30
  summaryPath: string;
29
31
  title: string;
30
32
  workspaceFolder: string | null;
@@ -33,6 +35,11 @@ type SummaryEntry = {
33
35
  workspaceUri: string | null;
34
36
  };
35
37
 
38
+ export type DeleteAntigravityConversationResult = {
39
+ deletedConversationIds: string[];
40
+ deletedPaths: string[];
41
+ };
42
+
36
43
  type ConversationFile = {
37
44
  bytes: number;
38
45
  mtimeMs: number;
@@ -48,7 +55,6 @@ const isSafeConversationId = (value: string) => SAFE_CONVERSATION_ID_PATTERN.tes
48
55
  type TranscriptFile = {
49
56
  bytes: number;
50
57
  entryCount: number;
51
- fullPath: string | null;
52
58
  mtimeMs: number;
53
59
  model: string | null;
54
60
  path: string;
@@ -271,8 +277,8 @@ const parseContextWorkspaceInfo = (field: ProtoField | null): WorkspaceInfo | nu
271
277
  // Antigravity summary parsing is reverse-engineered from agyhub_summaries_proto.pb:
272
278
  // entry field 1 = conversation id, entry field 2 = summary message. Inside that summary,
273
279
  // field 1 = title, 2 = indexed item count, 3 = last-updated timestamp, 7 = created timestamp,
274
- // 9 = workspace info, and 17 = context workspace info. parseWorkspaceInfo uses nested fields
275
- // 1/2 for URI variants; parseContextWorkspaceInfo uses field 7 or nested workspace field 1.
280
+ // 9 = workspace info, and 17 = context metadata. Context field 18 is the Antigravity
281
+ // project id; workspace parsing uses context field 7 or nested workspace field 1.
276
282
  const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): SummaryEntry | null => {
277
283
  try {
278
284
  const entryFields = nestedFields(entryField);
@@ -283,6 +289,7 @@ const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): Summary
283
289
  }
284
290
 
285
291
  const summaryFields = nestedFields(summaryBytes);
292
+ const contextFields = nestedFields(firstField(summaryFields, 17));
286
293
  const workspace =
287
294
  parseWorkspaceInfo(firstField(summaryFields, 9)) ??
288
295
  parseContextWorkspaceInfo(firstField(summaryFields, 17)) ??
@@ -294,6 +301,7 @@ const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): Summary
294
301
  createdAtMs: parseTimestampMs(firstField(summaryFields, 7)),
295
302
  indexedItemCount: fieldNumberValue(summaryFields, 2),
296
303
  lastUpdatedAtMs: parseTimestampMs(firstField(summaryFields, 3)),
304
+ projectId: fieldString(contextFields, 18),
297
305
  summaryPath,
298
306
  title: cleanTitle(fieldString(summaryFields, 1), conversationId),
299
307
  };
@@ -318,6 +326,84 @@ export const readAntigravitySummaryIndex = async (summaryPath: string): Promise<
318
326
  }
319
327
  };
320
328
 
329
+ const getFieldBounds = (
330
+ buffer: Uint8Array,
331
+ start: number,
332
+ ): {
333
+ end: number;
334
+ fieldNumber: number;
335
+ payloadEnd: number;
336
+ payloadStart: number;
337
+ wireType: number;
338
+ } => {
339
+ const key = readVarint(buffer, start, buffer.length);
340
+ const fieldNumber = key.value >> 3;
341
+ const wireType = key.value & 7;
342
+ let index = key.next;
343
+
344
+ if (wireType === 0) {
345
+ const value = readVarint(buffer, index, buffer.length);
346
+ return { end: value.next, fieldNumber, payloadEnd: value.next, payloadStart: index, wireType };
347
+ }
348
+
349
+ if (wireType === 1) {
350
+ return { end: index + 8, fieldNumber, payloadEnd: index + 8, payloadStart: index, wireType };
351
+ }
352
+
353
+ if (wireType === 2) {
354
+ const length = readVarint(buffer, index, buffer.length);
355
+ index = length.next;
356
+ return {
357
+ end: index + length.value,
358
+ fieldNumber,
359
+ payloadEnd: index + length.value,
360
+ payloadStart: index,
361
+ wireType,
362
+ };
363
+ }
364
+
365
+ if (wireType === 5) {
366
+ return { end: index + 4, fieldNumber, payloadEnd: index + 4, payloadStart: index, wireType };
367
+ }
368
+
369
+ throw new Error(`Unsupported protobuf wire type: ${wireType}`);
370
+ };
371
+
372
+ const removeConversationFromSummaryIndex = async (summaryPath: string, conversationId: string): Promise<boolean> => {
373
+ if (!(await pathExists(summaryPath))) {
374
+ return false;
375
+ }
376
+
377
+ const buffer = new Uint8Array(await Bun.file(summaryPath).arrayBuffer());
378
+ const retained: Uint8Array[] = [];
379
+ let removed = false;
380
+ let index = 0;
381
+
382
+ while (index < buffer.length) {
383
+ const bounds = getFieldBounds(buffer, index);
384
+ const fieldBytes = buffer.slice(index, bounds.end);
385
+ const shouldRemove =
386
+ bounds.fieldNumber === 1 &&
387
+ bounds.wireType === 2 &&
388
+ fieldString(parseProtoFields(buffer, bounds.payloadStart, bounds.payloadEnd), 1) === conversationId;
389
+
390
+ if (shouldRemove) {
391
+ removed = true;
392
+ } else {
393
+ retained.push(fieldBytes);
394
+ }
395
+
396
+ index = bounds.end;
397
+ }
398
+
399
+ if (!removed) {
400
+ return false;
401
+ }
402
+
403
+ await Bun.write(summaryPath, Buffer.concat(retained));
404
+ return true;
405
+ };
406
+
321
407
  const preferConversationFile = (
322
408
  current: ConversationFile | undefined,
323
409
  candidate: ConversationFile,
@@ -543,15 +629,22 @@ const preferTranscriptFile = (current: TranscriptFile | undefined, candidate: Tr
543
629
  return candidate;
544
630
  }
545
631
 
632
+ const sourceRank = (source: TranscriptFile['source']) => (source === 'transcript' ? 2 : 1);
633
+ const candidateRank = sourceRank(candidate.source);
634
+ const currentRank = sourceRank(current.source);
635
+ if (candidateRank !== currentRank) {
636
+ return candidateRank > currentRank ? candidate : current;
637
+ }
638
+
546
639
  if (candidate.mtimeMs !== current.mtimeMs) {
547
640
  return candidate.mtimeMs > current.mtimeMs ? candidate : current;
548
641
  }
549
642
 
550
- if (candidate.source !== current.source) {
551
- return candidate.source === 'overview' ? candidate : current;
643
+ if (candidate.entryCount !== current.entryCount) {
644
+ return candidate.entryCount > current.entryCount ? candidate : current;
552
645
  }
553
646
 
554
- return candidate.entryCount > current.entryCount ? candidate : current;
647
+ return candidate.bytes > current.bytes ? candidate : current;
555
648
  };
556
649
 
557
650
  const readTranscriptFileCandidate = async (
@@ -566,14 +659,10 @@ const readTranscriptFileCandidate = async (
566
659
  return null;
567
660
  }
568
661
 
569
- const fullPath = path.join(logsDir, 'transcript_full.jsonl');
570
- const hasFullTranscript = await pathExists(fullPath);
571
- const modelSourcePath = hasFullTranscript ? fullPath : transcriptPath;
572
662
  return {
573
663
  bytes: info.size,
574
- entryCount: await countJsonlEntries(modelSourcePath),
575
- fullPath: hasFullTranscript ? fullPath : null,
576
- model: await extractTranscriptModel(modelSourcePath),
664
+ entryCount: await countJsonlEntries(transcriptPath),
665
+ model: await extractTranscriptModel(transcriptPath),
577
666
  mtimeMs: info.mtimeMs,
578
667
  path: transcriptPath,
579
668
  root,
@@ -601,8 +690,9 @@ const readTranscriptFilesForRoot = async (root: string): Promise<Map<string, Tra
601
690
 
602
691
  const logsDir = path.join(brainDir, entry.name, '.system_generated', 'logs');
603
692
  for (const candidate of [
604
- { name: 'overview.txt', source: 'overview' as const },
693
+ { name: 'transcript_full.jsonl', source: 'transcript' as const },
605
694
  { name: 'transcript.jsonl', source: 'transcript' as const },
695
+ { name: 'overview.txt', source: 'overview' as const },
606
696
  ]) {
607
697
  const transcript = await readTranscriptFileCandidate(root, logsDir, candidate);
608
698
  if (transcript) {
@@ -689,6 +779,8 @@ const toConversation = (
689
779
  ): AntigravityConversation => {
690
780
  const fallbackTitle = artifacts[0]?.summary ?? conversationId;
691
781
  const artifactBytes = artifacts.reduce((total, artifact) => total + artifact.bytes, 0);
782
+ const conversationBytes = file?.bytes ?? 0;
783
+ const transcriptBytes = transcript?.bytes ?? 0;
692
784
  const workspace = resolveConversationWorkspace(summary, file, transcript, artifacts);
693
785
  const lastUpdatedAtMs = resolveConversationLastUpdatedAt(artifacts, file, summary, transcript);
694
786
  const sourceRoot = resolveConversationSourceRoot(file, transcript, artifacts);
@@ -697,7 +789,7 @@ const toConversation = (
697
789
  artifactBytes,
698
790
  artifactCount: artifacts.length,
699
791
  artifacts,
700
- conversationBytes: file?.bytes ?? 0,
792
+ conversationBytes,
701
793
  conversationId,
702
794
  conversationMtimeMs: file?.mtimeMs ?? null,
703
795
  conversationPath: file?.path ?? null,
@@ -705,10 +797,12 @@ const toConversation = (
705
797
  indexedItemCount: summary?.indexedItemCount ?? null,
706
798
  lastUpdatedAtMs,
707
799
  model: transcript?.model ?? null,
800
+ projectId: summary?.projectId ?? null,
708
801
  sourceRoot,
709
802
  summaryPath: summary?.summaryPath ?? null,
710
803
  title: summary?.title ?? cleanTitle(fallbackTitle, conversationId),
711
- transcriptBytes: transcript?.bytes ?? 0,
804
+ totalBytes: conversationBytes + transcriptBytes + artifactBytes,
805
+ transcriptBytes,
712
806
  transcriptEntryCount: transcript?.entryCount ?? 0,
713
807
  transcriptPath: transcript?.path ?? null,
714
808
  transcriptSource: resolveConversationTranscriptSource(file, transcript),
@@ -750,25 +844,30 @@ export const listAntigravityConversations = async (
750
844
 
751
845
  export const groupAntigravityConversations = (
752
846
  conversations: AntigravityConversation[],
847
+ projectNames: ReadonlyMap<string, string> = new Map(),
753
848
  ): AntigravityWorkspaceGroup[] => {
754
849
  const groups = new Map<string, AntigravityWorkspaceGroup>();
755
850
  for (const conversation of conversations) {
756
- const current = groups.get(conversation.workspaceKey) ?? {
851
+ const projectName = conversation.projectId ? projectNames.get(conversation.projectId) : null;
852
+ const groupKey = conversation.projectId ? `project:${conversation.projectId}` : conversation.workspaceKey;
853
+ const current = groups.get(groupKey) ?? {
757
854
  artifactCount: 0,
758
855
  conversationBytes: 0,
759
856
  conversationCount: 0,
760
- key: conversation.workspaceKey,
761
- label: conversation.workspaceLabel,
857
+ key: groupKey,
858
+ label: projectName ?? conversation.workspaceLabel,
762
859
  lastActiveMs: 0,
860
+ totalBytes: 0,
763
861
  transcriptCount: 0,
764
- uri: conversation.workspaceUri,
862
+ uri: conversation.projectId ? null : conversation.workspaceUri,
765
863
  };
766
864
  current.artifactCount += conversation.artifactCount;
767
865
  current.conversationBytes += conversation.conversationBytes;
768
866
  current.conversationCount += 1;
769
867
  current.lastActiveMs = Math.max(current.lastActiveMs, conversation.lastUpdatedAtMs ?? 0);
770
868
  current.transcriptCount += conversation.transcriptEntryCount > 0 ? 1 : 0;
771
- groups.set(conversation.workspaceKey, current);
869
+ current.totalBytes += conversation.totalBytes;
870
+ groups.set(groupKey, current);
772
871
  }
773
872
 
774
873
  return [...groups.values()].sort((a, b) => b.lastActiveMs - a.lastActiveMs || a.label.localeCompare(b.label));
@@ -777,16 +876,67 @@ export const groupAntigravityConversations = (
777
876
  export const listAntigravityWorkspaceGroups = async (
778
877
  roots = resolveAntigravityRoots(),
779
878
  ): Promise<AntigravityWorkspaceGroup[]> => {
780
- return groupAntigravityConversations(await listAntigravityConversations(roots));
879
+ const conversations = await listAntigravityConversations(roots);
880
+ const projectNames = await resolveAntigravityProjectNames(
881
+ conversations.flatMap((conversation) => (conversation.projectId ? [conversation.projectId] : [])),
882
+ );
883
+ return groupAntigravityConversations(conversations, projectNames);
781
884
  };
782
885
 
783
886
  export const listAntigravityConversationsForGroup = async (
784
887
  workspaceKey: string,
785
888
  roots = resolveAntigravityRoots(),
786
889
  ): Promise<AntigravityConversation[]> => {
787
- return (await listAntigravityConversations(roots)).filter(
788
- (conversation) => conversation.workspaceKey === workspaceKey,
789
- );
890
+ return (await listAntigravityConversations(roots)).filter((conversation) => {
891
+ const conversationGroupKey = conversation.projectId
892
+ ? `project:${conversation.projectId}`
893
+ : conversation.workspaceKey;
894
+ return conversationGroupKey === workspaceKey;
895
+ });
896
+ };
897
+
898
+ const existingAntigravityDeletePaths = async (root: string, conversationId: string): Promise<string[]> => {
899
+ const conversationPath = path.join(getAntigravityConversationDir(root), `${conversationId}.pb`);
900
+ const artifactDir = path.join(getAntigravityBrainDir(root), conversationId);
901
+ const logsDir = path.join(artifactDir, '.system_generated', 'logs');
902
+ const candidates = [
903
+ conversationPath,
904
+ path.join(logsDir, 'overview.txt'),
905
+ path.join(logsDir, 'transcript.jsonl'),
906
+ path.join(logsDir, 'transcript_full.jsonl'),
907
+ artifactDir,
908
+ ];
909
+ const exists = await Promise.all(candidates.map(pathExists));
910
+ return candidates.filter((_, index) => exists[index]);
911
+ };
912
+
913
+ export const deleteAntigravityConversation = async (
914
+ roots: string[],
915
+ conversationId: string,
916
+ ): Promise<DeleteAntigravityConversationResult> => {
917
+ if (!isSafeConversationId(conversationId)) {
918
+ return { deletedConversationIds: [], deletedPaths: [] };
919
+ }
920
+
921
+ const deletedPaths: string[] = [];
922
+ let deletedSummary = false;
923
+
924
+ for (const root of roots) {
925
+ deletedSummary =
926
+ (await removeConversationFromSummaryIndex(getAntigravitySummaryIndexPath(root), conversationId)) ||
927
+ deletedSummary;
928
+
929
+ const rootPaths = await existingAntigravityDeletePaths(root, conversationId);
930
+ deletedPaths.push(...rootPaths);
931
+
932
+ await rm(path.join(getAntigravityConversationDir(root), `${conversationId}.pb`), { force: true });
933
+ await rm(path.join(getAntigravityBrainDir(root), conversationId), { force: true, recursive: true });
934
+ }
935
+
936
+ return {
937
+ deletedConversationIds: deletedSummary || deletedPaths.length > 0 ? [conversationId] : [],
938
+ deletedPaths: [...new Set(deletedPaths)],
939
+ };
790
940
  };
791
941
 
792
942
  export const renderAntigravityArtifactsMarkdown = async (
@@ -918,7 +1068,7 @@ const renderLogEntry = (entry: AntigravityLogEntry): string[] => {
918
1068
  const heading = logEntryHeading(entry);
919
1069
  const timestamp = getString(entry.created_at);
920
1070
  const content = cleanLogContent(entry);
921
- const thinking = getString(entry.thinking);
1071
+ const thinking = getString(entry.thinking) ?? '';
922
1072
  const parts = [`## ${heading}`, ''];
923
1073
 
924
1074
  if (timestamp) {
@@ -1080,6 +1230,7 @@ const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConv
1080
1230
  conversation.transcriptSource === 'overview'
1081
1231
  ? 'antigravity_overview_transcript'
1082
1232
  : 'antigravity_jsonl_transcript';
1233
+ const sections = entries.flatMap((entry) => renderLogEntry(entry));
1083
1234
  const parts = [
1084
1235
  `# ${conversation.title}`,
1085
1236
  '',
@@ -1087,12 +1238,9 @@ const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConv
1087
1238
  `- conversation_id: \`${conversation.conversationId}\``,
1088
1239
  conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
1089
1240
  '',
1241
+ ...sections,
1090
1242
  ].filter(Boolean);
1091
1243
 
1092
- for (const entry of entries) {
1093
- parts.push(...renderLogEntry(entry));
1094
- }
1095
-
1096
1244
  return `${parts.join('\n').trimEnd()}\n`;
1097
1245
  };
1098
1246
 
@@ -1116,9 +1264,37 @@ const renderDecryptedSafeStorageMarkdown = (conversation: AntigravityConversatio
1116
1264
  .join('\n');
1117
1265
  };
1118
1266
 
1267
+ const renderAntigravitySummaryMarkdown = (conversation: AntigravityConversation): string | null => {
1268
+ if (conversation.artifactCount > 0) {
1269
+ return null;
1270
+ }
1271
+
1272
+ if (!conversation.summaryPath && conversation.indexedItemCount === null) {
1273
+ return null;
1274
+ }
1275
+
1276
+ 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()}\`` : '',
1285
+ '',
1286
+ 'No local transcript log was found for this Antigravity conversation. The export contains the summary index metadata that Antigravity retained.',
1287
+ '',
1288
+ ]
1289
+ .filter(Boolean)
1290
+ .join('\n');
1291
+ };
1292
+
1119
1293
  export const renderAntigravityConversationMarkdown = async (
1120
1294
  conversation: AntigravityConversation,
1121
- options: { keychainSecret?: string | null } = {},
1295
+ options: {
1296
+ keychainSecret?: string | null;
1297
+ } = {},
1122
1298
  ): Promise<string | null> => {
1123
1299
  const transcript = await renderAntigravityTranscriptMarkdown(conversation);
1124
1300
  if (transcript) {
@@ -1133,5 +1309,5 @@ export const renderAntigravityConversationMarkdown = async (
1133
1309
  }
1134
1310
  }
1135
1311
 
1136
- return null;
1312
+ return renderAntigravitySummaryMarkdown(conversation);
1137
1313
  };