spiracha 2.0.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 (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -1,4 +1,4 @@
1
- import { readdir, stat } from 'node:fs/promises';
1
+ import { readdir, stat, unlink } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  type ClaudeCodeSessionSummary,
@@ -9,7 +9,13 @@ import {
9
9
  getDefaultClaudeCodeDataDir,
10
10
  resolveClaudeCodeProjectsDir,
11
11
  } from './claude-code-exporter-types';
12
+ import {
13
+ getClaudeCodeAssistantMessagePhase,
14
+ isClaudeCodeRawFlagEnabled,
15
+ isClaudeCodeSyntheticTranscriptEntry,
16
+ } from './claude-code-transcript-phase';
12
17
  import { mapWithConcurrency } from './concurrency';
18
+ import { getPortablePathBasename } from './portable-path';
13
19
  import {
14
20
  asBoolean,
15
21
  asNumber,
@@ -17,9 +23,9 @@ import {
17
23
  asString,
18
24
  cleanExtractedText,
19
25
  cleanInlineTitle,
20
- getPortablePathBasename,
21
26
  isWorkspacePathQuery,
22
27
  type JsonValue,
28
+ readDirectoryEntriesIfExists,
23
29
  readJsonlObjects,
24
30
  workspacePathMatchesQuery,
25
31
  } from './shared';
@@ -38,6 +44,18 @@ type ParsedTranscriptFile = {
38
44
  transcript: ClaudeCodeSessionTranscript;
39
45
  };
40
46
 
47
+ type ReadTranscriptFileOptions = {
48
+ includeRawPayloads?: boolean;
49
+ maxRawPayloadFileSizeBytes?: number;
50
+ };
51
+
52
+ export type ReadClaudeCodeSessionTranscriptOptions = ReadTranscriptFileOptions;
53
+
54
+ export type DeleteClaudeCodeSessionResult = {
55
+ deletedFiles: string[];
56
+ deletedSessionIds: string[];
57
+ };
58
+
41
59
  type SessionStats = {
42
60
  assistantMessageCount: number;
43
61
  attachmentCount: number;
@@ -52,6 +70,11 @@ type SessionStats = {
52
70
  userMessageCount: number;
53
71
  };
54
72
 
73
+ type SessionStatsTracker = {
74
+ assistantMessageIds: Set<string>;
75
+ usageMessageIds: Set<string>;
76
+ };
77
+
55
78
  type SessionIdentity = {
56
79
  cwd: string | null;
57
80
  firstUserText: string | null;
@@ -75,6 +98,18 @@ const pathExists = async (target: string): Promise<boolean> => {
75
98
  .catch(() => false);
76
99
  };
77
100
 
101
+ const unlinkIfPresent = async (target: string): Promise<boolean> => {
102
+ try {
103
+ await unlink(target);
104
+ return true;
105
+ } catch (error) {
106
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
107
+ return false;
108
+ }
109
+ throw error;
110
+ }
111
+ };
112
+
78
113
  const cleanLabel = (value: string | null | undefined): string | null => {
79
114
  const cleaned = value?.replace(/\s+/g, ' ').trim();
80
115
  return cleaned ? cleaned : null;
@@ -281,19 +316,21 @@ const parseTranscriptEntry = (raw: Record<string, JsonValue>): ClaudeCodeTranscr
281
316
  const type = asString(raw.type ?? null) ?? 'unknown';
282
317
  const message = asObject(raw.message ?? null);
283
318
  const parts = getTranscriptEntryParts(type, message, raw);
319
+ const role = getTranscriptEntryRole(type, message);
284
320
 
285
321
  if (parts.length === 0) {
286
322
  return null;
287
323
  }
288
324
 
289
325
  return {
326
+ assistantPhase: getClaudeCodeAssistantMessagePhase({ parts, raw, role }),
290
327
  cwd: asString(raw.cwd ?? null),
291
328
  entryId: getTranscriptEntryId(type, raw),
292
329
  model: asString(message?.model ?? null),
293
330
  parentEntryId: asString(raw.parentUuid ?? null),
294
331
  parts,
295
332
  raw,
296
- role: getTranscriptEntryRole(type, message),
333
+ role,
297
334
  timestamp: asString(raw.timestamp ?? null),
298
335
  type,
299
336
  };
@@ -313,30 +350,86 @@ const createEmptyStats = (): SessionStats => ({
313
350
  userMessageCount: 0,
314
351
  });
315
352
 
316
- const addUsageStats = (stats: SessionStats, message: Record<string, JsonValue> | null) => {
353
+ const createStatsTracker = (): SessionStatsTracker => ({
354
+ assistantMessageIds: new Set(),
355
+ usageMessageIds: new Set(),
356
+ });
357
+
358
+ const getMessageIdentity = (entry: ClaudeCodeTranscriptEntry): string => {
359
+ const message = asObject(entry.raw.message ?? null);
360
+ return asString(message?.id ?? null) ?? entry.entryId;
361
+ };
362
+
363
+ const addUsageStats = (stats: SessionStats, tracker: SessionStatsTracker, entry: ClaudeCodeTranscriptEntry) => {
364
+ const message = asObject(entry.raw.message ?? null);
317
365
  const usage = asObject(message?.usage ?? null);
318
366
  if (!usage) {
319
367
  return;
320
368
  }
321
369
 
370
+ const messageId = getMessageIdentity(entry);
371
+ if (tracker.usageMessageIds.has(messageId)) {
372
+ return;
373
+ }
374
+ tracker.usageMessageIds.add(messageId);
375
+
322
376
  stats.inputTokens += asNumber(usage.input_tokens ?? null) ?? 0;
323
377
  stats.outputTokens += asNumber(usage.output_tokens ?? null) ?? 0;
324
378
  stats.cacheCreationInputTokens += asNumber(usage.cache_creation_input_tokens ?? null) ?? 0;
325
379
  stats.cacheReadInputTokens += asNumber(usage.cache_read_input_tokens ?? null) ?? 0;
326
380
  };
327
381
 
328
- const updateStatsFromEntry = (stats: SessionStats, entry: ClaudeCodeTranscriptEntry) => {
329
- if (entry.type === 'user' || entry.type === 'assistant') {
330
- stats.messageCount += 1;
382
+ const hasTextPart = (entry: ClaudeCodeTranscriptEntry): boolean => {
383
+ return entry.parts.some((part) => part.type === 'text' && part.text?.trim());
384
+ };
385
+
386
+ const extractUserPromptText = (text: string): string | null => {
387
+ const cleaned = cleanExtractedText(text).trim();
388
+ if (!cleaned.startsWith('<!-- attach -->')) {
389
+ return cleaned || null;
331
390
  }
332
391
 
333
- if (entry.role === 'user') {
392
+ const lines = cleaned.split('\n').slice(1);
393
+ let sawQuotedAttachment = false;
394
+ for (const [index, line] of lines.entries()) {
395
+ const trimmed = line.trim();
396
+ if (trimmed.startsWith('>')) {
397
+ sawQuotedAttachment = true;
398
+ continue;
399
+ }
400
+ if (!trimmed) {
401
+ continue;
402
+ }
403
+ if (sawQuotedAttachment) {
404
+ return lines.slice(index).join('\n').trim() || null;
405
+ }
406
+ }
407
+
408
+ return null;
409
+ };
410
+
411
+ const updateMessageStats = (stats: SessionStats, tracker: SessionStatsTracker, entry: ClaudeCodeTranscriptEntry) => {
412
+ if (entry.role === 'user' && hasTextPart(entry) && !isClaudeCodeSyntheticTranscriptEntry(entry)) {
413
+ stats.messageCount += 1;
334
414
  stats.userMessageCount += 1;
415
+ return;
416
+ }
417
+
418
+ if (entry.role !== 'assistant' || !hasTextPart(entry)) {
419
+ return;
335
420
  }
336
421
 
337
- if (entry.role === 'assistant') {
338
- stats.assistantMessageCount += 1;
422
+ const messageId = getMessageIdentity(entry);
423
+ if (tracker.assistantMessageIds.has(messageId)) {
424
+ return;
339
425
  }
426
+ tracker.assistantMessageIds.add(messageId);
427
+ stats.messageCount += 1;
428
+ stats.assistantMessageCount += 1;
429
+ };
430
+
431
+ const updateStatsFromEntry = (stats: SessionStats, tracker: SessionStatsTracker, entry: ClaudeCodeTranscriptEntry) => {
432
+ updateMessageStats(stats, tracker, entry);
340
433
 
341
434
  if (entry.type === 'attachment') {
342
435
  stats.attachmentCount += 1;
@@ -345,7 +438,7 @@ const updateStatsFromEntry = (stats: SessionStats, entry: ClaudeCodeTranscriptEn
345
438
  stats.toolCallCount += entry.parts.filter((part) => part.type === 'tool_use').length;
346
439
  stats.toolResultCount += entry.parts.filter((part) => part.type === 'tool_result').length;
347
440
  stats.renderablePartCount += entry.parts.filter(isRenderablePart).length;
348
- addUsageStats(stats, asObject(entry.raw.message ?? null));
441
+ addUsageStats(stats, tracker, entry);
349
442
  };
350
443
 
351
444
  const updateTimeline = (
@@ -382,9 +475,9 @@ const updateIdentityFromRaw = (identity: SessionIdentity, raw: Record<string, Js
382
475
  };
383
476
 
384
477
  const updateIdentityFromEntry = (identity: SessionIdentity, entry: ClaudeCodeTranscriptEntry) => {
385
- if (entry.role === 'user' && !identity.firstUserText) {
478
+ if (entry.role === 'user' && !identity.firstUserText && !isClaudeCodeSyntheticTranscriptEntry(entry)) {
386
479
  const textPart = entry.parts.find((part) => part.type === 'text' && part.text?.trim());
387
- identity.firstUserText = cleanExtractedText(textPart?.text ?? '').trim() || null;
480
+ identity.firstUserText = extractUserPromptText(textPart?.text ?? '');
388
481
  }
389
482
  };
390
483
 
@@ -450,10 +543,59 @@ const isRenderablePart = (part: ClaudeCodeTranscriptPart): boolean => {
450
543
  return false;
451
544
  };
452
545
 
453
- const readTranscriptFile = async (file: TranscriptFile): Promise<ParsedTranscriptFile | null> => {
454
- const fallbackMtimeMs = await stat(file.filePath)
455
- .then((stats) => stats.mtimeMs)
456
- .catch(() => null);
546
+ const stripEntryRawPayloads = (entry: ClaudeCodeTranscriptEntry): ClaudeCodeTranscriptEntry => {
547
+ return {
548
+ ...entry,
549
+ parts: entry.parts.map((part) => ({ ...part, raw: {} })),
550
+ raw: {},
551
+ };
552
+ };
553
+
554
+ const markIncompleteAssistantParentAsCommentary = (
555
+ entries: ClaudeCodeTranscriptEntry[],
556
+ entry: ClaudeCodeTranscriptEntry,
557
+ ): void => {
558
+ const interruptedByUser =
559
+ entry.role === 'user' &&
560
+ entry.parts.some(
561
+ (part) => part.type === 'text' && part.text?.trim().startsWith('[Request interrupted by user]'),
562
+ );
563
+ if ((!interruptedByUser && !isClaudeCodeRawFlagEnabled(entry.raw.isApiErrorMessage)) || !entry.parentEntryId) {
564
+ return;
565
+ }
566
+
567
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
568
+ const candidate = entries[index];
569
+ if (candidate?.entryId === entry.parentEntryId && candidate.role === 'assistant') {
570
+ candidate.assistantPhase = 'commentary';
571
+ return;
572
+ }
573
+ }
574
+ };
575
+
576
+ const markLatestAssistantAsCommentary = (entries: ClaudeCodeTranscriptEntry[]): void => {
577
+ const latestEntry = entries.at(-1);
578
+ if (latestEntry?.role === 'assistant') {
579
+ latestEntry.assistantPhase = 'commentary';
580
+ }
581
+ };
582
+
583
+ const isTaskNotification = (raw: Record<string, JsonValue>): boolean => {
584
+ const content =
585
+ raw.type === 'queue-operation'
586
+ ? asString(raw.content ?? null)
587
+ : raw.type === 'user'
588
+ ? textFromContentValue(asObject(raw.message ?? null)?.content)
589
+ : null;
590
+ return content?.trimStart().startsWith('<task-notification>') === true;
591
+ };
592
+
593
+ const buildTranscriptFromRawEvents = (
594
+ file: TranscriptFile,
595
+ sourceRawEvents: Record<string, JsonValue>[],
596
+ fallbackMtimeMs: number | null,
597
+ includeRawPayloads: boolean,
598
+ ): ClaudeCodeSessionTranscript => {
457
599
  const identity: SessionIdentity = {
458
600
  cwd: null,
459
601
  firstUserText: null,
@@ -464,39 +606,72 @@ const readTranscriptFile = async (file: TranscriptFile): Promise<ParsedTranscrip
464
606
  version: null,
465
607
  };
466
608
  const stats = createEmptyStats();
609
+ const statsTracker = createStatsTracker();
467
610
  const timeline = { firstMs: null as number | null, lastMs: null as number | null };
468
611
  const entries: ClaudeCodeTranscriptEntry[] = [];
469
- const rawEvents: Record<string, JsonValue>[] = [];
470
612
 
471
- for await (const raw of readJsonlObjects(file.filePath)) {
472
- rawEvents.push(raw);
613
+ for (const raw of sourceRawEvents) {
473
614
  updateTimeline(timeline, raw);
474
615
  updateIdentityFromRaw(identity, raw);
475
616
 
617
+ if (isTaskNotification(raw)) {
618
+ markLatestAssistantAsCommentary(entries);
619
+ }
620
+
476
621
  const entry = parseTranscriptEntry(raw);
477
622
  if (!entry) {
478
623
  continue;
479
624
  }
480
625
 
481
- entries.push(entry);
482
- updateStatsFromEntry(stats, entry);
626
+ markIncompleteAssistantParentAsCommentary(entries, entry);
627
+ if (isClaudeCodeSyntheticTranscriptEntry(entry)) {
628
+ continue;
629
+ }
630
+
631
+ entries.push(includeRawPayloads ? entry : stripEntryRawPayloads(entry));
632
+ updateStatsFromEntry(stats, statsTracker, entry);
483
633
  updateIdentityFromEntry(identity, entry);
484
634
  }
485
635
 
486
636
  const session = toSessionSummary(file, identity, stats, toTimeline(timeline, fallbackMtimeMs));
487
637
  return {
488
- transcript: {
489
- entries,
490
- rawEvents,
491
- renderablePartCount: stats.renderablePartCount,
492
- session,
493
- },
638
+ entries,
639
+ rawEvents: includeRawPayloads ? sourceRawEvents : [],
640
+ rawPayloadsOmitted: includeRawPayloads ? undefined : true,
641
+ renderablePartCount: stats.renderablePartCount,
642
+ session,
643
+ };
644
+ };
645
+
646
+ const readTranscriptFile = async (
647
+ file: TranscriptFile,
648
+ options: ReadTranscriptFileOptions = {},
649
+ ): Promise<ParsedTranscriptFile | null> => {
650
+ const includeRawPayloads = options.includeRawPayloads ?? true;
651
+ const fallbackMtimeMs = await stat(file.filePath)
652
+ .then((stats) => stats.mtimeMs)
653
+ .catch(() => null);
654
+ const rawEvents: Record<string, JsonValue>[] = [];
655
+
656
+ try {
657
+ for await (const raw of readJsonlObjects(file.filePath)) {
658
+ rawEvents.push(raw);
659
+ }
660
+ } catch (error) {
661
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
662
+ return null;
663
+ }
664
+ throw error;
665
+ }
666
+
667
+ return {
668
+ transcript: buildTranscriptFromRawEvents(file, rawEvents, fallbackMtimeMs, includeRawPayloads),
494
669
  };
495
670
  };
496
671
 
497
672
  const listTranscriptFilesForProject = async (projectsDir: string, directoryName: string): Promise<TranscriptFile[]> => {
498
673
  const projectDir = path.join(projectsDir, directoryName);
499
- const entries = await readdir(projectDir, { withFileTypes: true }).catch(() => []);
674
+ const entries = await readDirectoryEntriesIfExists(projectDir);
500
675
  return entries
501
676
  .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
502
677
  .map((entry) => ({
@@ -521,16 +696,245 @@ const listTranscriptFiles = async (projectsDir: string): Promise<TranscriptFile[
521
696
  };
522
697
 
523
698
  const readTranscriptFiles = async (files: TranscriptFile[]): Promise<ClaudeCodeSessionTranscript[]> => {
524
- const parsed = await mapWithConcurrency(files, READ_CONCURRENCY, readTranscriptFile);
699
+ const parsed = await mapWithConcurrency(files, READ_CONCURRENCY, (file) => readTranscriptFile(file));
525
700
  return parsed.flatMap((item) => (item ? [item.transcript] : []));
526
701
  };
527
702
 
703
+ const getCompactionSummaryId = (raw: Record<string, JsonValue>): string | null => {
704
+ return raw.isCompactSummary === true ? asString(raw.uuid ?? null) : null;
705
+ };
706
+
707
+ const getCompactionSummaryIds = (transcript: ClaudeCodeSessionTranscript): string[] => {
708
+ return [...new Set(transcript.rawEvents.map(getCompactionSummaryId).filter((id): id is string => id !== null))];
709
+ };
710
+
711
+ type TranscriptLineageIndex = {
712
+ anchorsByIndex: string[][];
713
+ indexesByAnchor: Map<string, number[]>;
714
+ };
715
+
716
+ const indexTranscriptLineageAnchors = (transcripts: ClaudeCodeSessionTranscript[]): TranscriptLineageIndex => {
717
+ const indexesByAnchor = new Map<string, number[]>();
718
+ const anchorsByIndex = transcripts.map((transcript, index) => {
719
+ const anchors = getCompactionSummaryIds(transcript).map(
720
+ (anchorId) => `${transcript.session.workspaceKey}\0${anchorId}`,
721
+ );
722
+ for (const anchor of anchors) {
723
+ const indexes = indexesByAnchor.get(anchor) ?? [];
724
+ indexes.push(index);
725
+ indexesByAnchor.set(anchor, indexes);
726
+ }
727
+ return anchors;
728
+ });
729
+
730
+ return { anchorsByIndex, indexesByAnchor };
731
+ };
732
+
733
+ const collectTranscriptLineage = (
734
+ startIndex: number,
735
+ transcripts: ClaudeCodeSessionTranscript[],
736
+ index: TranscriptLineageIndex,
737
+ visited: Set<number>,
738
+ ): ClaudeCodeSessionTranscript[] => {
739
+ const lineage: ClaudeCodeSessionTranscript[] = [];
740
+ const pending = [startIndex];
741
+ visited.add(startIndex);
742
+
743
+ while (pending.length > 0) {
744
+ const currentIndex = pending.pop() as number;
745
+ const transcript = transcripts[currentIndex];
746
+ if (!transcript) {
747
+ continue;
748
+ }
749
+ lineage.push(transcript);
750
+
751
+ const neighborIndexes = (index.anchorsByIndex[currentIndex] ?? []).flatMap(
752
+ (anchor) => index.indexesByAnchor.get(anchor) ?? [],
753
+ );
754
+ for (const neighborIndex of neighborIndexes) {
755
+ if (!visited.has(neighborIndex)) {
756
+ visited.add(neighborIndex);
757
+ pending.push(neighborIndex);
758
+ }
759
+ }
760
+ }
761
+
762
+ return lineage;
763
+ };
764
+
765
+ const getTranscriptLineages = (transcripts: ClaudeCodeSessionTranscript[]): ClaudeCodeSessionTranscript[][] => {
766
+ const index = indexTranscriptLineageAnchors(transcripts);
767
+ const visited = new Set<number>();
768
+ const lineages: ClaudeCodeSessionTranscript[][] = [];
769
+
770
+ for (const startIndex of transcripts.keys()) {
771
+ if (visited.has(startIndex)) {
772
+ continue;
773
+ }
774
+ lineages.push(collectTranscriptLineage(startIndex, transcripts, index, visited));
775
+ }
776
+
777
+ return lineages;
778
+ };
779
+
780
+ const countContentEntriesBefore = (
781
+ transcript: ClaudeCodeSessionTranscript,
782
+ rawEventIndex: number,
783
+ prefixCounts: WeakMap<ClaudeCodeSessionTranscript, number[]>,
784
+ ): number => {
785
+ let counts = prefixCounts.get(transcript);
786
+ if (!counts) {
787
+ counts = [0];
788
+ for (const raw of transcript.rawEvents) {
789
+ const entry = parseTranscriptEntry(raw);
790
+ counts.push((counts.at(-1) ?? 0) + (entry && !isClaudeCodeSyntheticTranscriptEntry(entry) ? 1 : 0));
791
+ }
792
+ prefixCounts.set(transcript, counts);
793
+ }
794
+ return counts[Math.max(0, Math.min(rawEventIndex, counts.length - 1))] ?? 0;
795
+ };
796
+
797
+ const deduplicateRawEvents = (rawEvents: Record<string, JsonValue>[]): Record<string, JsonValue>[] => {
798
+ const seenIds = new Set<string>();
799
+ return rawEvents.filter((raw) => {
800
+ const eventId = asString(raw.uuid ?? null);
801
+ if (!eventId) {
802
+ return true;
803
+ }
804
+ if (seenIds.has(eventId)) {
805
+ return false;
806
+ }
807
+ seenIds.add(eventId);
808
+ return true;
809
+ });
810
+ };
811
+
812
+ const MAX_CLAUDE_LINEAGE_DEPTH = 64;
813
+
814
+ const buildLogicalRawEvents = (
815
+ lineage: ClaudeCodeSessionTranscript[],
816
+ current: ClaudeCodeSessionTranscript,
817
+ context: {
818
+ memo: WeakMap<ClaudeCodeSessionTranscript, Record<string, JsonValue>[]>;
819
+ prefixCounts: WeakMap<ClaudeCodeSessionTranscript, number[]>;
820
+ } = { memo: new WeakMap(), prefixCounts: new WeakMap() },
821
+ visiting = new Set<string>(),
822
+ depth = 0,
823
+ ): Record<string, JsonValue>[] => {
824
+ const cached = context.memo.get(current);
825
+ if (cached) {
826
+ return cached;
827
+ }
828
+
829
+ const finish = (events: Record<string, JsonValue>[]) => {
830
+ const deduplicated = deduplicateRawEvents(events);
831
+ context.memo.set(current, deduplicated);
832
+ return deduplicated;
833
+ };
834
+
835
+ if (visiting.has(current.session.sessionId) || depth >= MAX_CLAUDE_LINEAGE_DEPTH) {
836
+ return deduplicateRawEvents(current.rawEvents);
837
+ }
838
+
839
+ const firstAnchorIndex = current.rawEvents.findIndex((raw) => getCompactionSummaryId(raw) !== null);
840
+ const firstAnchor = firstAnchorIndex < 0 ? null : getCompactionSummaryId(current.rawEvents[firstAnchorIndex] ?? {});
841
+ if (!firstAnchor) {
842
+ return finish(current.rawEvents);
843
+ }
844
+
845
+ const currentPrefixSize = countContentEntriesBefore(current, firstAnchorIndex, context.prefixCounts);
846
+ const ancestor = lineage
847
+ .filter((candidate) => candidate.session.sessionId !== current.session.sessionId)
848
+ .map((candidate) => {
849
+ const anchorIndex = candidate.rawEvents.findIndex((raw) => getCompactionSummaryId(raw) === firstAnchor);
850
+ return {
851
+ anchorIndex,
852
+ candidate,
853
+ prefixSize:
854
+ anchorIndex < 0 ? -1 : countContentEntriesBefore(candidate, anchorIndex, context.prefixCounts),
855
+ };
856
+ })
857
+ .filter((candidate) => candidate.prefixSize > currentPrefixSize)
858
+ .sort(
859
+ (left, right) =>
860
+ right.prefixSize - left.prefixSize ||
861
+ (left.candidate.session.createdAtMs ?? 0) - (right.candidate.session.createdAtMs ?? 0),
862
+ )[0];
863
+ if (!ancestor) {
864
+ return finish(current.rawEvents);
865
+ }
866
+
867
+ const nextVisiting = new Set(visiting);
868
+ nextVisiting.add(current.session.sessionId);
869
+ const ancestorEvents = buildLogicalRawEvents(lineage, ancestor.candidate, context, nextVisiting, depth + 1);
870
+ const ancestorAnchorIndex = ancestorEvents.findIndex((raw) => getCompactionSummaryId(raw) === firstAnchor);
871
+ if (ancestorAnchorIndex < 0) {
872
+ return finish(current.rawEvents);
873
+ }
874
+
875
+ return finish([...ancestorEvents.slice(0, ancestorAnchorIndex), ...current.rawEvents.slice(firstAnchorIndex)]);
876
+ };
877
+
878
+ const compareTranscriptsByActivity = (
879
+ left: ClaudeCodeSessionTranscript,
880
+ right: ClaudeCodeSessionTranscript,
881
+ ): number => {
882
+ return (
883
+ compareNullableMsDesc(left.session.lastActiveAtMs, right.session.lastActiveAtMs) ||
884
+ left.session.sessionId.localeCompare(right.session.sessionId)
885
+ );
886
+ };
887
+
888
+ const coalesceTranscriptLineage = (lineage: ClaudeCodeSessionTranscript[]): ClaudeCodeSessionTranscript | null => {
889
+ const canonical = [...lineage].sort(compareTranscriptsByActivity)[0];
890
+ if (!canonical) {
891
+ return null;
892
+ }
893
+
894
+ const directoryName = getDirectoryNameFromWorkspaceKey(canonical.session.workspaceKey);
895
+ if (!directoryName) {
896
+ return canonical;
897
+ }
898
+
899
+ const transcript = buildTranscriptFromRawEvents(
900
+ { directoryName, filePath: canonical.session.filePath },
901
+ buildLogicalRawEvents(lineage, canonical),
902
+ canonical.session.lastActiveAtMs,
903
+ true,
904
+ );
905
+ transcript.session.filePath = canonical.session.filePath;
906
+ transcript.session.sessionId = canonical.session.sessionId;
907
+ return transcript;
908
+ };
909
+
910
+ const coalesceTranscriptLineages = (transcripts: ClaudeCodeSessionTranscript[]): ClaudeCodeSessionTranscript[] => {
911
+ return getTranscriptLineages(transcripts).flatMap((lineage) => {
912
+ const transcript = coalesceTranscriptLineage(lineage);
913
+ return transcript ? [transcript] : [];
914
+ });
915
+ };
916
+
917
+ const omitTranscriptRawPayloads = (transcript: ClaudeCodeSessionTranscript): ClaudeCodeSessionTranscript => ({
918
+ ...transcript,
919
+ entries: transcript.entries.map(stripEntryRawPayloads),
920
+ rawEvents: [],
921
+ rawPayloadsOmitted: true,
922
+ });
923
+
924
+ const hasSessionContent = (transcript: ClaudeCodeSessionTranscript): boolean => {
925
+ return transcript.session.userMessageCount > 0 || transcript.session.assistantMessageCount > 0;
926
+ };
927
+
528
928
  const compareNullableMsDesc = (left: number | null, right: number | null): number => {
529
929
  return (right ?? 0) - (left ?? 0);
530
930
  };
531
931
 
532
932
  const toWorkspaceGroup = (directoryName: string, sessions: ClaudeCodeSessionSummary[]): ClaudeCodeWorkspaceGroup => {
533
- const worktree = sessions[0]?.worktree ?? decodeWorktreeFromDirectoryName(directoryName);
933
+ const decodedWorktree = decodeWorktreeFromDirectoryName(directoryName);
934
+ const worktree =
935
+ sessions.find((session) => session.worktree !== decodedWorktree)?.worktree ??
936
+ sessions[0]?.worktree ??
937
+ decodedWorktree;
534
938
  const lastActiveAtMs = sessions.reduce<number | null>((latest, session) => {
535
939
  if (session.lastActiveAtMs === null) {
536
940
  return latest;
@@ -560,7 +964,7 @@ export const listClaudeCodeWorkspaceGroups = async (
560
964
  projectsDir = resolveClaudeCodeProjectsDir(),
561
965
  ): Promise<ClaudeCodeWorkspaceGroup[]> => {
562
966
  const files = await listTranscriptFiles(projectsDir);
563
- const transcripts = await readTranscriptFiles(files);
967
+ const transcripts = coalesceTranscriptLineages(await readTranscriptFiles(files));
564
968
  const sessionsByDirectory = new Map<string, ClaudeCodeSessionSummary[]>();
565
969
 
566
970
  for (const transcript of transcripts) {
@@ -569,6 +973,10 @@ export const listClaudeCodeWorkspaceGroups = async (
569
973
  continue;
570
974
  }
571
975
 
976
+ if (!hasSessionContent(transcript)) {
977
+ continue;
978
+ }
979
+
572
980
  const sessions = sessionsByDirectory.get(directoryName) ?? [];
573
981
  sessions.push(transcript.session);
574
982
  sessionsByDirectory.set(directoryName, sessions);
@@ -612,35 +1020,56 @@ export const findClaudeCodeWorkspaceGroups = (
612
1020
  return groups.filter((group) => claudeCodeWorkspaceMatchesQuery(group, query));
613
1021
  };
614
1022
 
1023
+ const compareSessions = (left: ClaudeCodeSessionSummary, right: ClaudeCodeSessionSummary) =>
1024
+ compareNullableMsDesc(left.lastActiveAtMs, right.lastActiveAtMs) || left.title.localeCompare(right.title);
1025
+
615
1026
  const sortSessions = (sessions: ClaudeCodeSessionSummary[]): ClaudeCodeSessionSummary[] => {
616
- return [...sessions].sort(
617
- (left, right) =>
618
- compareNullableMsDesc(left.lastActiveAtMs, right.lastActiveAtMs) || left.title.localeCompare(right.title),
619
- );
1027
+ return [...sessions].sort(compareSessions);
620
1028
  };
621
1029
 
622
- export const listClaudeCodeSessionsForGroup = async (
1030
+ export const listClaudeCodeSessionTranscriptsForGroup = async (
623
1031
  workspaceKey: string,
624
1032
  projectsDir = resolveClaudeCodeProjectsDir(),
625
- ): Promise<ClaudeCodeSessionSummary[]> => {
1033
+ ): Promise<ClaudeCodeSessionTranscript[]> => {
626
1034
  const directoryName = getDirectoryNameFromWorkspaceKey(workspaceKey);
627
1035
  if (!directoryName || !(await pathExists(projectsDir))) {
628
1036
  return [];
629
1037
  }
630
1038
 
631
1039
  const files = await listTranscriptFilesForProject(projectsDir, directoryName);
632
- const transcripts = await readTranscriptFiles(files);
633
- return sortSessions(transcripts.map((transcript) => transcript.session));
1040
+ const transcripts = coalesceTranscriptLineages(await readTranscriptFiles(files));
1041
+ return transcripts.filter(hasSessionContent).sort((left, right) => compareSessions(left.session, right.session));
1042
+ };
1043
+
1044
+ export const listClaudeCodeSessionsForGroup = async (
1045
+ workspaceKey: string,
1046
+ projectsDir = resolveClaudeCodeProjectsDir(),
1047
+ ): Promise<ClaudeCodeSessionSummary[]> => {
1048
+ return sortSessions(
1049
+ (await listClaudeCodeSessionTranscriptsForGroup(workspaceKey, projectsDir)).map(
1050
+ (transcript) => transcript.session,
1051
+ ),
1052
+ );
634
1053
  };
635
1054
 
636
1055
  const locateSessionFile = async (projectsDir: string, sessionId: string): Promise<TranscriptFile | null> => {
637
1056
  const files = await listTranscriptFiles(projectsDir);
638
- return files.find((file) => path.basename(file.filePath, '.jsonl') === sessionId) ?? null;
1057
+ const filenameMatch = files.find((file) => path.basename(file.filePath, '.jsonl') === sessionId);
1058
+ if (filenameMatch) {
1059
+ return filenameMatch;
1060
+ }
1061
+
1062
+ const bodyMatches = await mapWithConcurrency(files, READ_CONCURRENCY, async (file) => {
1063
+ const parsed = await readTranscriptFile(file, { includeRawPayloads: false });
1064
+ return parsed?.transcript.session.sessionId === sessionId ? file : null;
1065
+ });
1066
+ return bodyMatches.find((file): file is TranscriptFile => file !== null) ?? null;
639
1067
  };
640
1068
 
641
1069
  export const readClaudeCodeSessionTranscript = async (
642
1070
  projectsDir: string,
643
1071
  sessionId: string,
1072
+ options: ReadClaudeCodeSessionTranscriptOptions = {},
644
1073
  ): Promise<ClaudeCodeSessionTranscript | null> => {
645
1074
  if (!(await pathExists(projectsDir))) {
646
1075
  return null;
@@ -651,5 +1080,68 @@ export const readClaudeCodeSessionTranscript = async (
651
1080
  return null;
652
1081
  }
653
1082
 
654
- return (await readTranscriptFile(file))?.transcript ?? null;
1083
+ const files = await listTranscriptFilesForProject(projectsDir, file.directoryName);
1084
+ const transcripts = await readTranscriptFiles(files);
1085
+ const lineage = getTranscriptLineages(transcripts).find((candidate) =>
1086
+ candidate.some((transcript) => transcript.session.sessionId === sessionId),
1087
+ );
1088
+ if (!lineage) {
1089
+ return null;
1090
+ }
1091
+
1092
+ const transcript = coalesceTranscriptLineage(lineage);
1093
+ if (!transcript) {
1094
+ return null;
1095
+ }
1096
+ const lineageFileSizeBytes =
1097
+ options.maxRawPayloadFileSizeBytes === undefined
1098
+ ? 0
1099
+ : (
1100
+ await Promise.all(
1101
+ lineage.map((candidate) =>
1102
+ stat(candidate.session.filePath)
1103
+ .then((metadata) => metadata.size)
1104
+ .catch(() => options.maxRawPayloadFileSizeBytes! + 1),
1105
+ ),
1106
+ )
1107
+ ).reduce((total, size) => total + size, 0);
1108
+ const omitRawPayloads =
1109
+ options.includeRawPayloads === false ||
1110
+ (options.maxRawPayloadFileSizeBytes !== undefined && lineageFileSizeBytes > options.maxRawPayloadFileSizeBytes);
1111
+ return omitRawPayloads ? omitTranscriptRawPayloads(transcript) : transcript;
1112
+ };
1113
+
1114
+ export const deleteClaudeCodeSession = async (
1115
+ projectsDir: string,
1116
+ sessionId: string,
1117
+ ): Promise<DeleteClaudeCodeSessionResult> => {
1118
+ if (!(await pathExists(projectsDir))) {
1119
+ return { deletedFiles: [], deletedSessionIds: [] };
1120
+ }
1121
+
1122
+ const file = await locateSessionFile(projectsDir, sessionId);
1123
+ if (!file) {
1124
+ return { deletedFiles: [], deletedSessionIds: [] };
1125
+ }
1126
+
1127
+ const files = await listTranscriptFilesForProject(projectsDir, file.directoryName);
1128
+ const transcripts = await readTranscriptFiles(files);
1129
+ const lineage = getTranscriptLineages(transcripts).find((candidate) =>
1130
+ candidate.some((transcript) => transcript.session.sessionId === sessionId),
1131
+ );
1132
+ const targets = (lineage ?? [])
1133
+ .map((transcript) => ({
1134
+ filePath: transcript.session.filePath,
1135
+ sessionId: transcript.session.sessionId,
1136
+ }))
1137
+ .sort((left, right) => left.filePath.localeCompare(right.filePath));
1138
+ if (targets.length === 0) {
1139
+ return { deletedFiles: [], deletedSessionIds: [] };
1140
+ }
1141
+
1142
+ const removed = await Promise.all(targets.map((target) => unlinkIfPresent(target.filePath)));
1143
+ return {
1144
+ deletedFiles: targets.flatMap((target, index) => (removed[index] ? [target.filePath] : [])),
1145
+ deletedSessionIds: targets.flatMap((target, index) => (removed[index] ? [target.sessionId] : [])),
1146
+ };
655
1147
  };