spiracha 1.4.0 → 1.5.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 (159) hide show
  1. package/AGENTS.md +48 -5
  2. package/README.md +40 -5
  3. package/apps/ui/AGENTS.md +23 -4
  4. package/apps/ui/README.md +49 -2
  5. package/apps/ui/dist/client/assets/analytics-DOy1b6Sh.js +1 -0
  6. package/apps/ui/dist/client/assets/{antigravity-conversations._conversationId-DmD0sN8X.js → antigravity-conversations._conversationId-DqySVO2K.js} +2 -2
  7. package/apps/ui/dist/client/assets/antigravity-keychain-panel-B77qJz6f.js +1 -0
  8. package/apps/ui/dist/client/assets/antigravity._workspaceKey-B3NM6gRH.js +1 -0
  9. package/apps/ui/dist/client/assets/{antigravity.index-DEEkjlZa.js → antigravity.index-DP9JC2P9.js} +1 -1
  10. package/apps/ui/dist/client/assets/{badge-DfWvPfKF.js → badge-C3i4OKs8.js} +1 -1
  11. package/apps/ui/dist/client/assets/claude-code-sessions._sessionId-8pQ55ci2.js +1 -0
  12. package/apps/ui/dist/client/assets/claude-code-sessions._sessionId-DVz5UmtU.js +1 -0
  13. package/apps/ui/dist/client/assets/claude-code._workspaceKey-BBKskSum.js +1 -0
  14. package/apps/ui/dist/client/assets/claude-code._workspaceKey-OkikMUFs.js +1 -0
  15. package/apps/ui/dist/client/assets/claude-code.index-Cwu76dbb.js +1 -0
  16. package/apps/ui/dist/client/assets/claude-code.index-D6eMd08t.js +1 -0
  17. package/apps/ui/dist/client/assets/createServerFn-B-KkwmHU.js +3 -0
  18. package/apps/ui/dist/client/assets/cursor-threads._composerId-hBWlP1D-.js +1 -0
  19. package/apps/ui/dist/client/assets/cursor._workspaceKey-BFPkIRi-.js +1 -0
  20. package/apps/ui/dist/client/assets/cursor.index-8y4o20kb.js +2 -0
  21. package/apps/ui/dist/client/assets/delete-confirm-dialog-Bxs07u-j.js +7 -0
  22. package/apps/ui/dist/client/assets/dist-BrJhN2A0.js +5 -0
  23. package/apps/ui/dist/client/assets/{dist-BhZOxAPP.js → dist-Cmd3AIfD.js} +1 -1
  24. package/apps/ui/dist/client/assets/dropdown-menu-D5cvPaZt.js +1 -0
  25. package/apps/ui/dist/client/assets/{es2015-BkCWttyM.js → es2015-ad6040dw.js} +1 -1
  26. package/apps/ui/dist/client/assets/export-dialog-Dr8d2MqD.js +1 -0
  27. package/apps/ui/dist/client/assets/{formatters-BM9kB7ed.js → formatters-BtqyrX_c.js} +1 -1
  28. package/apps/ui/dist/client/assets/index-C-STgIH4.js +166 -0
  29. package/apps/ui/dist/client/assets/kiro-sessions._sessionId-CKgfJ7GR.js +1 -0
  30. package/apps/ui/dist/client/assets/kiro-sessions._sessionId-zvIJHtQF.js +1 -0
  31. package/apps/ui/dist/client/assets/kiro._workspaceKey-DhVuJuZB.js +1 -0
  32. package/apps/ui/dist/client/assets/kiro._workspaceKey-QArsjkTY.js +1 -0
  33. package/apps/ui/dist/client/assets/kiro.index-C-fr4m7i.js +1 -0
  34. package/apps/ui/dist/client/assets/kiro.index-OYgAdtZd.js +1 -0
  35. package/apps/ui/dist/client/assets/opencode-sessions._sessionId-DLjiTst-.js +1 -0
  36. package/apps/ui/dist/client/assets/opencode-sessions._sessionId-biBwrRbt.js +8 -0
  37. package/apps/ui/dist/client/assets/opencode._workspaceKey-C7OIzMOJ.js +1 -0
  38. package/apps/ui/dist/client/assets/opencode._workspaceKey-sBakX0oI.js +1 -0
  39. package/apps/ui/dist/client/assets/opencode.index-Bl_kNs9N.js +1 -0
  40. package/apps/ui/dist/client/assets/opencode.index-CN53bVmb.js +1 -0
  41. package/apps/ui/dist/client/assets/preload-helper-B8fFrdxC.js +1 -0
  42. package/apps/ui/dist/client/assets/projects._project-D8WloxQN.js +1 -0
  43. package/apps/ui/dist/client/assets/projects.index-5xRtuJdX.js +3 -0
  44. package/apps/ui/dist/client/assets/routes-DnzF_9CX.js +1 -0
  45. package/apps/ui/dist/client/assets/select-CYF6pF37.js +1 -0
  46. package/apps/ui/dist/client/assets/settings-Dx70jgP_.js +1 -0
  47. package/apps/ui/dist/client/assets/styles-B7Zi9EFA.css +1 -0
  48. package/apps/ui/dist/client/assets/tabs-CmuhkT-c.js +7 -0
  49. package/apps/ui/dist/client/assets/thread-transcript-stats-BUNz6u7F.js +1 -0
  50. package/apps/ui/dist/client/assets/threads._threadId-BMRSFow3.js +1 -0
  51. package/apps/ui/dist/client/assets/useMutation-BSFult2i.js +1 -0
  52. package/apps/ui/dist/client/assets/useQuery-CjINKZf1.js +1 -0
  53. package/apps/ui/dist/client/icon.svg +2 -1
  54. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CQ8Jjs3B.js +387 -0
  55. package/apps/ui/dist/server/assets/{antigravity-conversations._conversationId-BtexWY-K.js → antigravity-conversations._conversationId-BKOyL2T6.js} +2 -2
  56. package/apps/ui/dist/server/assets/{antigravity-conversations._conversationId-zMrR6v6R.js → antigravity-conversations._conversationId-eS87Dx7g.js} +1 -1
  57. package/apps/ui/dist/server/assets/claude-code-db-Ik3VStKZ.js +361 -0
  58. package/apps/ui/dist/server/assets/claude-code-queries-Cgzi4K-e.js +37 -0
  59. package/apps/ui/dist/server/assets/claude-code-server-PfraClXS.js +72 -0
  60. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-C55oe6zj.js +18 -0
  61. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-D1RYYi3j.js +473 -0
  62. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-DSvPLEUL.js +11 -0
  63. package/apps/ui/dist/server/assets/claude-code-transcript-C-bxh3cS.js +139 -0
  64. package/apps/ui/dist/server/assets/claude-code-transcript-phase-DmUMc8VU.js +10 -0
  65. package/apps/ui/dist/server/assets/claude-code._workspaceKey-B5Xg_-tE.js +28 -0
  66. package/apps/ui/dist/server/assets/claude-code._workspaceKey-BPlzyXPa.js +11 -0
  67. package/apps/ui/dist/server/assets/claude-code._workspaceKey-DqDve2wl.js +191 -0
  68. package/apps/ui/dist/server/assets/claude-code.index-B7yYR7dD.js +92 -0
  69. package/apps/ui/dist/server/assets/claude-code.index-BUHThG1W.js +11 -0
  70. package/apps/ui/dist/server/assets/{codex-server-CNXSJuc2.js → codex-server-CGXU4H8-.js} +319 -29
  71. package/apps/ui/dist/server/assets/{cursor-db-cYZEU3WQ.js → cursor-db-CZnYy7aT.js} +3 -3
  72. package/apps/ui/dist/server/assets/{cursor-recovery-nq-kR62j.js → cursor-recovery-CXpO8KcD.js} +2 -2
  73. package/apps/ui/dist/server/assets/{cursor-server-C3q7hrp-.js → cursor-server-DNu52A3K.js} +13 -13
  74. package/apps/ui/dist/server/assets/{cursor-threads._composerId-BkMMTQ3v.js → cursor-threads._composerId-Ckn6ItHI.js} +3 -3
  75. package/apps/ui/dist/server/assets/{cursor-threads._composerId-BPJFWfJj.js → cursor-threads._composerId-p9-ARAzc.js} +1 -1
  76. package/apps/ui/dist/server/assets/{cursor-transcript-2iL3KFSK.js → cursor-transcript-B7tGnqtn.js} +1 -1
  77. package/apps/ui/dist/server/assets/{cursor._workspaceKey-C5PdTaKB.js → cursor._workspaceKey-CZuohIUL.js} +1 -1
  78. package/apps/ui/dist/server/assets/{cursor._workspaceKey-Ckj7apKI.js → cursor._workspaceKey-DPp5JSjy.js} +2 -2
  79. package/apps/ui/dist/server/assets/{export-dialog-BItjWgkZ.js → export-dialog-D88ze9Gy.js} +2 -2
  80. package/apps/ui/dist/server/assets/kiro-db--fXrED_-.js +552 -0
  81. package/apps/ui/dist/server/assets/kiro-queries-CzNlM2ok.js +37 -0
  82. package/apps/ui/dist/server/assets/kiro-server-9WRxg2Au.js +72 -0
  83. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-CRY2c7YO.js +11 -0
  84. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-F1UlwxmR.js +449 -0
  85. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-bvTulT7C.js +18 -0
  86. package/apps/ui/dist/server/assets/kiro-transcript-DoekQBaH.js +112 -0
  87. package/apps/ui/dist/server/assets/kiro-transcript-phase-CJG40nQg.js +27 -0
  88. package/apps/ui/dist/server/assets/kiro._workspaceKey-BDe9sQwo.js +11 -0
  89. package/apps/ui/dist/server/assets/kiro._workspaceKey-CJsjgNwO.js +28 -0
  90. package/apps/ui/dist/server/assets/kiro._workspaceKey-D_-4Qb7y.js +193 -0
  91. package/apps/ui/dist/server/assets/kiro.index-C__-UROk.js +11 -0
  92. package/apps/ui/dist/server/assets/kiro.index-CmTbqeYj.js +93 -0
  93. package/apps/ui/dist/server/assets/opencode-db-BM7KjOzc.js +397 -0
  94. package/apps/ui/dist/server/assets/opencode-queries-BeNYXnQv.js +50 -0
  95. package/apps/ui/dist/server/assets/opencode-server-BpQ_kRze.js +72 -0
  96. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-CGQ-t0vD.js +518 -0
  97. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-D8Q7in_Z.js +18 -0
  98. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-DKOvi0lV.js +11 -0
  99. package/apps/ui/dist/server/assets/opencode-think-tags-CAoD-EcZ.js +87 -0
  100. package/apps/ui/dist/server/assets/opencode-transcript-BNTcGNym.js +141 -0
  101. package/apps/ui/dist/server/assets/opencode-transcript-phase-OqLkiQD0.js +32 -0
  102. package/apps/ui/dist/server/assets/opencode._workspaceKey-BdqcWHw1.js +11 -0
  103. package/apps/ui/dist/server/assets/opencode._workspaceKey-CfH96HTT.js +28 -0
  104. package/apps/ui/dist/server/assets/opencode._workspaceKey-DdbHPDrO.js +207 -0
  105. package/apps/ui/dist/server/assets/opencode.index-BbVzkJXP.js +11 -0
  106. package/apps/ui/dist/server/assets/opencode.index-Vh_Gq_1L.js +93 -0
  107. package/apps/ui/dist/server/assets/{projects._project-Bpbc3C-L.js → projects._project-4io5LO0E.js} +1 -1
  108. package/apps/ui/dist/server/assets/{projects._project-CvZho6EQ.js → projects._project-D6ZoGhJ8.js} +2 -2
  109. package/apps/ui/dist/server/assets/{router-6m-ihwqA.js → router-Sac2DGk6.js} +157 -49
  110. package/apps/ui/dist/server/assets/{shared-CPRNYIql.js → shared-DyhChtHf.js} +4 -1
  111. package/apps/ui/dist/server/assets/source-session-export-server-DNutCawb.js +38 -0
  112. package/apps/ui/dist/server/assets/{start-CI_0bSiY.js → start-B_HFwkkk.js} +1 -1
  113. package/apps/ui/dist/server/assets/{tabs-CGA13IZM.js → tabs-DkaChNVg.js} +0 -1
  114. package/apps/ui/dist/server/assets/thread-transcript-stats-Dh34mt2u.js +45 -0
  115. package/apps/ui/dist/server/assets/{threads._threadId-D2GYkVn6.js → threads._threadId-BeXEc4Lo.js} +15 -6
  116. package/apps/ui/dist/server/assets/{threads._threadId-Bs0yuqO9.js → threads._threadId-Ci0S4mKU.js} +1 -1
  117. package/apps/ui/dist/server/server.js +72 -24
  118. package/package.json +21 -4
  119. package/src/lib/claude-code-db.ts +655 -0
  120. package/src/lib/claude-code-exporter-types.ts +110 -0
  121. package/src/lib/claude-code-transcript-phase.ts +25 -0
  122. package/src/lib/claude-code-transcript.ts +180 -0
  123. package/src/lib/codex-browser-db.ts +490 -31
  124. package/src/lib/codex-browser-export.ts +18 -2
  125. package/src/lib/codex-thread-cache.ts +17 -1
  126. package/src/lib/concurrency.ts +25 -0
  127. package/src/lib/kiro-db.ts +949 -0
  128. package/src/lib/kiro-exporter-types.ts +100 -0
  129. package/src/lib/kiro-transcript-phase.ts +41 -0
  130. package/src/lib/kiro-transcript.ts +115 -0
  131. package/src/lib/opencode-db.ts +610 -0
  132. package/src/lib/opencode-exporter-types.ts +129 -0
  133. package/src/lib/opencode-think-tags.ts +126 -0
  134. package/src/lib/opencode-transcript-phase.ts +50 -0
  135. package/src/lib/opencode-transcript.ts +194 -0
  136. package/src/lib/shared.ts +25 -0
  137. package/src/lib/ui-export-archive.ts +61 -0
  138. package/apps/ui/dist/client/assets/analytics-DK2jdvmI.js +0 -1
  139. package/apps/ui/dist/client/assets/antigravity-keychain-panel-BRHVjFAM.js +0 -1
  140. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CGV7amas.js +0 -1
  141. package/apps/ui/dist/client/assets/createServerFn-CgRRVpBH.js +0 -3
  142. package/apps/ui/dist/client/assets/cursor-threads._composerId-Ti2gTm63.js +0 -1
  143. package/apps/ui/dist/client/assets/cursor._workspaceKey-DzD_c6j9.js +0 -1
  144. package/apps/ui/dist/client/assets/cursor.index-PkCFVcl4.js +0 -2
  145. package/apps/ui/dist/client/assets/delete-confirm-dialog-BHRmyLHr.js +0 -11
  146. package/apps/ui/dist/client/assets/dropdown-menu-Dy5cLzTy.js +0 -1
  147. package/apps/ui/dist/client/assets/export-dialog-C86SYHJn.js +0 -1
  148. package/apps/ui/dist/client/assets/index-CPvAP-jk.js +0 -149
  149. package/apps/ui/dist/client/assets/projects._project-Ckf6muWZ.js +0 -1
  150. package/apps/ui/dist/client/assets/projects.index-V5ibNZII.js +0 -3
  151. package/apps/ui/dist/client/assets/routes-Df2tgpyd.js +0 -1
  152. package/apps/ui/dist/client/assets/select-Bf2GwfPR.js +0 -1
  153. package/apps/ui/dist/client/assets/settings-5VQIzME0.js +0 -1
  154. package/apps/ui/dist/client/assets/styles-BhRkXgwB.css +0 -1
  155. package/apps/ui/dist/client/assets/tabs-OO4VU5KR.js +0 -7
  156. package/apps/ui/dist/client/assets/threads._threadId-BgDhFj2I.js +0 -1
  157. package/apps/ui/dist/client/assets/useMutation-CC_B7uy5.js +0 -1
  158. package/apps/ui/dist/client/assets/useQuery-C5By6WKU.js +0 -1
  159. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CRW2kqj0.js +0 -232
@@ -0,0 +1,655 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ type ClaudeCodeSessionSummary,
5
+ type ClaudeCodeSessionTranscript,
6
+ type ClaudeCodeTranscriptEntry,
7
+ type ClaudeCodeTranscriptPart,
8
+ type ClaudeCodeWorkspaceGroup,
9
+ getDefaultClaudeCodeDataDir,
10
+ resolveClaudeCodeProjectsDir,
11
+ } from './claude-code-exporter-types';
12
+ import { mapWithConcurrency } from './concurrency';
13
+ import {
14
+ asBoolean,
15
+ asNumber,
16
+ asObject,
17
+ asString,
18
+ cleanExtractedText,
19
+ cleanInlineTitle,
20
+ getPortablePathBasename,
21
+ isWorkspacePathQuery,
22
+ type JsonValue,
23
+ readJsonlObjects,
24
+ workspacePathMatchesQuery,
25
+ } from './shared';
26
+
27
+ export { getDefaultClaudeCodeDataDir, resolveClaudeCodeProjectsDir };
28
+
29
+ const READ_CONCURRENCY = 8;
30
+ const WORKSPACE_KEY_PREFIX = 'project:';
31
+
32
+ type TranscriptFile = {
33
+ directoryName: string;
34
+ filePath: string;
35
+ };
36
+
37
+ type ParsedTranscriptFile = {
38
+ transcript: ClaudeCodeSessionTranscript;
39
+ };
40
+
41
+ type SessionStats = {
42
+ assistantMessageCount: number;
43
+ attachmentCount: number;
44
+ cacheCreationInputTokens: number;
45
+ cacheReadInputTokens: number;
46
+ inputTokens: number;
47
+ messageCount: number;
48
+ outputTokens: number;
49
+ renderablePartCount: number;
50
+ toolCallCount: number;
51
+ toolResultCount: number;
52
+ userMessageCount: number;
53
+ };
54
+
55
+ type SessionIdentity = {
56
+ cwd: string | null;
57
+ firstUserText: string | null;
58
+ gitBranch: string | null;
59
+ model: string | null;
60
+ sessionId: string;
61
+ title: string | null;
62
+ version: string | null;
63
+ };
64
+
65
+ type SessionTimeline = {
66
+ createdAtIso: string | null;
67
+ createdAtMs: number | null;
68
+ lastActiveAtIso: string | null;
69
+ lastActiveAtMs: number | null;
70
+ };
71
+
72
+ const pathExists = async (target: string): Promise<boolean> => {
73
+ return await stat(target)
74
+ .then(() => true)
75
+ .catch(() => false);
76
+ };
77
+
78
+ const cleanLabel = (value: string | null | undefined): string | null => {
79
+ const cleaned = value?.replace(/\s+/g, ' ').trim();
80
+ return cleaned ? cleaned : null;
81
+ };
82
+
83
+ const getWorkspaceKey = (directoryName: string): string => `${WORKSPACE_KEY_PREFIX}${directoryName}`;
84
+
85
+ const getDirectoryNameFromWorkspaceKey = (workspaceKey: string): string | null => {
86
+ return workspaceKey.startsWith(WORKSPACE_KEY_PREFIX) ? workspaceKey.slice(WORKSPACE_KEY_PREFIX.length) : null;
87
+ };
88
+
89
+ const decodeWorktreeFromDirectoryName = (directoryName: string): string => {
90
+ if (!directoryName.startsWith('-')) {
91
+ return directoryName.replace(/-/g, path.sep);
92
+ }
93
+
94
+ return path.join(path.sep, ...directoryName.slice(1).split('-').filter(Boolean));
95
+ };
96
+
97
+ const getWorkspaceLabel = (worktree: string): string => {
98
+ if (worktree === path.sep) {
99
+ return '(global)';
100
+ }
101
+
102
+ return getPortablePathBasename(worktree) || worktree;
103
+ };
104
+
105
+ const getWorkspaceUri = (worktree: string): string => {
106
+ return worktree.startsWith(path.sep) ? `file://${worktree}` : worktree;
107
+ };
108
+
109
+ const toIso = (value: number | null): string | null => {
110
+ return value === null ? null : new Date(value).toISOString();
111
+ };
112
+
113
+ const parseTimestampMs = (value: JsonValue | undefined): number | null => {
114
+ if (typeof value === 'number' && Number.isFinite(value)) {
115
+ return value;
116
+ }
117
+
118
+ if (typeof value !== 'string' || !value.trim()) {
119
+ return null;
120
+ }
121
+
122
+ const numeric = Number(value);
123
+ if (Number.isFinite(numeric)) {
124
+ return numeric;
125
+ }
126
+
127
+ const parsed = Date.parse(value);
128
+ return Number.isFinite(parsed) ? parsed : null;
129
+ };
130
+
131
+ const formatJsonLike = (value: JsonValue | undefined): string | null => {
132
+ if (value === undefined || value === null) {
133
+ return null;
134
+ }
135
+
136
+ if (typeof value === 'string') {
137
+ return value;
138
+ }
139
+
140
+ return JSON.stringify(value, null, 2);
141
+ };
142
+
143
+ const textFromContentValue = (value: JsonValue | undefined): string => {
144
+ if (typeof value === 'string') {
145
+ return value;
146
+ }
147
+
148
+ if (Array.isArray(value)) {
149
+ return value
150
+ .map((item) => {
151
+ if (typeof item === 'string') {
152
+ return item;
153
+ }
154
+
155
+ const object = asObject(item);
156
+ return asString(object?.text ?? null) ?? '';
157
+ })
158
+ .filter(Boolean)
159
+ .join('\n\n');
160
+ }
161
+
162
+ const object = asObject(value ?? null);
163
+ return asString(object?.text ?? null) ?? '';
164
+ };
165
+
166
+ const parseTextContentPart = (item: Record<string, JsonValue>): ClaudeCodeTranscriptPart => ({
167
+ raw: item,
168
+ text: asString(item.text ?? null) ?? '',
169
+ type: 'text',
170
+ });
171
+
172
+ const parseThinkingContentPart = (item: Record<string, JsonValue>): ClaudeCodeTranscriptPart => ({
173
+ raw: item,
174
+ text: asString(item.thinking ?? null) ?? '',
175
+ type: 'thinking',
176
+ });
177
+
178
+ const parseToolUseContentPart = (item: Record<string, JsonValue>): ClaudeCodeTranscriptPart => ({
179
+ argumentsText: formatJsonLike(item.input),
180
+ raw: item,
181
+ toolName: asString(item.name ?? null) ?? 'unknown',
182
+ toolUseId: asString(item.id ?? null),
183
+ type: 'tool_use',
184
+ });
185
+
186
+ const parseToolResultContentPart = (item: Record<string, JsonValue>): ClaudeCodeTranscriptPart => ({
187
+ isError: asBoolean(item.is_error ?? null),
188
+ outputText: cleanExtractedText(textFromContentValue(item.content)).trim(),
189
+ raw: item,
190
+ toolUseId: asString(item.tool_use_id ?? null),
191
+ type: 'tool_result',
192
+ });
193
+
194
+ const parseFallbackContentPart = (item: Record<string, JsonValue>): ClaudeCodeTranscriptPart => {
195
+ const fallbackText = asString(item.text ?? null);
196
+ return {
197
+ raw: item,
198
+ text: fallbackText ?? undefined,
199
+ type: fallbackText ? 'text' : 'unknown',
200
+ };
201
+ };
202
+
203
+ const structuredContentParsers: Record<string, (item: Record<string, JsonValue>) => ClaudeCodeTranscriptPart> = {
204
+ input_text: parseTextContentPart,
205
+ output_text: parseTextContentPart,
206
+ text: parseTextContentPart,
207
+ thinking: parseThinkingContentPart,
208
+ tool_result: parseToolResultContentPart,
209
+ tool_use: parseToolUseContentPart,
210
+ };
211
+
212
+ const parseStructuredContentPart = (item: Record<string, JsonValue>): ClaudeCodeTranscriptPart => {
213
+ const type = asString(item.type ?? null);
214
+ return type
215
+ ? (structuredContentParsers[type]?.(item) ?? parseFallbackContentPart(item))
216
+ : parseFallbackContentPart(item);
217
+ };
218
+
219
+ const parseMessageContentPart = (item: JsonValue): ClaudeCodeTranscriptPart | null => {
220
+ if (typeof item === 'string') {
221
+ return {
222
+ raw: { text: item },
223
+ text: item,
224
+ type: 'text',
225
+ };
226
+ }
227
+
228
+ const object = asObject(item);
229
+ return object ? parseStructuredContentPart(object) : null;
230
+ };
231
+
232
+ const parseMessageParts = (message: Record<string, JsonValue>): ClaudeCodeTranscriptPart[] => {
233
+ const content = message.content;
234
+ const items = Array.isArray(content) ? content : [content];
235
+ return items
236
+ .map(parseMessageContentPart)
237
+ .filter((part): part is ClaudeCodeTranscriptPart => part !== null && part.type !== 'unknown');
238
+ };
239
+
240
+ const parseAttachmentParts = (raw: Record<string, JsonValue>): ClaudeCodeTranscriptPart[] => {
241
+ const attachment = asObject(raw.attachment ?? null);
242
+ if (!attachment) {
243
+ return [];
244
+ }
245
+
246
+ return [
247
+ {
248
+ attachmentType: asString(attachment.type ?? null),
249
+ raw: attachment,
250
+ text: textFromContentValue(attachment.content),
251
+ type: 'attachment',
252
+ },
253
+ ];
254
+ };
255
+
256
+ const getTranscriptEntryParts = (
257
+ type: string,
258
+ message: Record<string, JsonValue> | null,
259
+ raw: Record<string, JsonValue>,
260
+ ): ClaudeCodeTranscriptPart[] => {
261
+ if (message) {
262
+ return parseMessageParts(message);
263
+ }
264
+
265
+ if (type === 'attachment') {
266
+ return parseAttachmentParts(raw);
267
+ }
268
+
269
+ return [];
270
+ };
271
+
272
+ const getTranscriptEntryRole = (type: string, message: Record<string, JsonValue> | null): string => {
273
+ return asString(message?.role ?? null) ?? (type === 'attachment' ? 'attachment' : type);
274
+ };
275
+
276
+ const getTranscriptEntryId = (type: string, raw: Record<string, JsonValue>): string => {
277
+ return asString(raw.uuid ?? null) ?? `${type}:${asString(raw.sessionId ?? null) ?? 'unknown'}`;
278
+ };
279
+
280
+ const parseTranscriptEntry = (raw: Record<string, JsonValue>): ClaudeCodeTranscriptEntry | null => {
281
+ const type = asString(raw.type ?? null) ?? 'unknown';
282
+ const message = asObject(raw.message ?? null);
283
+ const parts = getTranscriptEntryParts(type, message, raw);
284
+
285
+ if (parts.length === 0) {
286
+ return null;
287
+ }
288
+
289
+ return {
290
+ cwd: asString(raw.cwd ?? null),
291
+ entryId: getTranscriptEntryId(type, raw),
292
+ model: asString(message?.model ?? null),
293
+ parentEntryId: asString(raw.parentUuid ?? null),
294
+ parts,
295
+ raw,
296
+ role: getTranscriptEntryRole(type, message),
297
+ timestamp: asString(raw.timestamp ?? null),
298
+ type,
299
+ };
300
+ };
301
+
302
+ const createEmptyStats = (): SessionStats => ({
303
+ assistantMessageCount: 0,
304
+ attachmentCount: 0,
305
+ cacheCreationInputTokens: 0,
306
+ cacheReadInputTokens: 0,
307
+ inputTokens: 0,
308
+ messageCount: 0,
309
+ outputTokens: 0,
310
+ renderablePartCount: 0,
311
+ toolCallCount: 0,
312
+ toolResultCount: 0,
313
+ userMessageCount: 0,
314
+ });
315
+
316
+ const addUsageStats = (stats: SessionStats, message: Record<string, JsonValue> | null) => {
317
+ const usage = asObject(message?.usage ?? null);
318
+ if (!usage) {
319
+ return;
320
+ }
321
+
322
+ stats.inputTokens += asNumber(usage.input_tokens ?? null) ?? 0;
323
+ stats.outputTokens += asNumber(usage.output_tokens ?? null) ?? 0;
324
+ stats.cacheCreationInputTokens += asNumber(usage.cache_creation_input_tokens ?? null) ?? 0;
325
+ stats.cacheReadInputTokens += asNumber(usage.cache_read_input_tokens ?? null) ?? 0;
326
+ };
327
+
328
+ const updateStatsFromEntry = (stats: SessionStats, entry: ClaudeCodeTranscriptEntry) => {
329
+ if (entry.type === 'user' || entry.type === 'assistant') {
330
+ stats.messageCount += 1;
331
+ }
332
+
333
+ if (entry.role === 'user') {
334
+ stats.userMessageCount += 1;
335
+ }
336
+
337
+ if (entry.role === 'assistant') {
338
+ stats.assistantMessageCount += 1;
339
+ }
340
+
341
+ if (entry.type === 'attachment') {
342
+ stats.attachmentCount += 1;
343
+ }
344
+
345
+ stats.toolCallCount += entry.parts.filter((part) => part.type === 'tool_use').length;
346
+ stats.toolResultCount += entry.parts.filter((part) => part.type === 'tool_result').length;
347
+ stats.renderablePartCount += entry.parts.filter(isRenderablePart).length;
348
+ addUsageStats(stats, asObject(entry.raw.message ?? null));
349
+ };
350
+
351
+ const updateTimeline = (
352
+ timeline: { firstMs: number | null; lastMs: number | null },
353
+ raw: Record<string, JsonValue>,
354
+ ) => {
355
+ const timestampMs = parseTimestampMs(raw.timestamp);
356
+ if (timestampMs === null) {
357
+ return;
358
+ }
359
+
360
+ timeline.firstMs = timeline.firstMs === null ? timestampMs : Math.min(timeline.firstMs, timestampMs);
361
+ timeline.lastMs = timeline.lastMs === null ? timestampMs : Math.max(timeline.lastMs, timestampMs);
362
+ };
363
+
364
+ const readTitleCandidate = (raw: Record<string, JsonValue>): string | null => {
365
+ const type = asString(raw.type ?? null);
366
+ if (type !== 'ai-title' && type !== 'custom-title') {
367
+ return null;
368
+ }
369
+
370
+ return cleanLabel(asString(raw.title ?? null) ?? asString(raw.content ?? null));
371
+ };
372
+
373
+ const updateIdentityFromRaw = (identity: SessionIdentity, raw: Record<string, JsonValue>) => {
374
+ identity.sessionId = asString(raw.sessionId ?? null) ?? identity.sessionId;
375
+ identity.cwd = asString(raw.cwd ?? null) ?? identity.cwd;
376
+ identity.version = asString(raw.version ?? null) ?? identity.version;
377
+ identity.gitBranch = asString(raw.gitBranch ?? null) ?? identity.gitBranch;
378
+
379
+ const message = asObject(raw.message ?? null);
380
+ identity.model = asString(message?.model ?? null) ?? identity.model;
381
+ identity.title = readTitleCandidate(raw) ?? identity.title;
382
+ };
383
+
384
+ const updateIdentityFromEntry = (identity: SessionIdentity, entry: ClaudeCodeTranscriptEntry) => {
385
+ if (entry.role === 'user' && !identity.firstUserText) {
386
+ const textPart = entry.parts.find((part) => part.type === 'text' && part.text?.trim());
387
+ identity.firstUserText = cleanExtractedText(textPart?.text ?? '').trim() || null;
388
+ }
389
+ };
390
+
391
+ const toTimeline = (
392
+ timeline: { firstMs: number | null; lastMs: number | null },
393
+ fallbackMs: number | null,
394
+ ): SessionTimeline => {
395
+ const createdAtMs = timeline.firstMs ?? fallbackMs;
396
+ const lastActiveAtMs = timeline.lastMs ?? fallbackMs;
397
+ return {
398
+ createdAtIso: toIso(createdAtMs),
399
+ createdAtMs,
400
+ lastActiveAtIso: toIso(lastActiveAtMs),
401
+ lastActiveAtMs,
402
+ };
403
+ };
404
+
405
+ const getTitle = (identity: SessionIdentity): string => {
406
+ return cleanInlineTitle(identity.title ?? identity.firstUserText ?? identity.sessionId);
407
+ };
408
+
409
+ const toSessionSummary = (
410
+ file: TranscriptFile,
411
+ identity: SessionIdentity,
412
+ stats: SessionStats,
413
+ timeline: SessionTimeline,
414
+ ): ClaudeCodeSessionSummary => {
415
+ const worktree = identity.cwd ?? decodeWorktreeFromDirectoryName(file.directoryName);
416
+ const workspaceLabel = getWorkspaceLabel(worktree);
417
+ const totalTokens =
418
+ stats.inputTokens + stats.outputTokens + stats.cacheCreationInputTokens + stats.cacheReadInputTokens;
419
+
420
+ return {
421
+ ...stats,
422
+ ...timeline,
423
+ cwd: identity.cwd,
424
+ filePath: file.filePath,
425
+ gitBranch: identity.gitBranch,
426
+ model: identity.model,
427
+ sessionId: identity.sessionId,
428
+ title: getTitle(identity),
429
+ totalTokens,
430
+ version: identity.version,
431
+ workspaceKey: getWorkspaceKey(file.directoryName),
432
+ workspaceLabel,
433
+ worktree,
434
+ };
435
+ };
436
+
437
+ const isRenderablePart = (part: ClaudeCodeTranscriptPart): boolean => {
438
+ if (part.type === 'text' || part.type === 'thinking' || part.type === 'attachment') {
439
+ return Boolean(part.text?.trim());
440
+ }
441
+
442
+ if (part.type === 'tool_use') {
443
+ return Boolean(part.toolName || part.argumentsText?.trim());
444
+ }
445
+
446
+ if (part.type === 'tool_result') {
447
+ return Boolean(part.outputText?.trim());
448
+ }
449
+
450
+ return false;
451
+ };
452
+
453
+ const readTranscriptFile = async (file: TranscriptFile): Promise<ParsedTranscriptFile | null> => {
454
+ const fallbackMtimeMs = await stat(file.filePath)
455
+ .then((stats) => stats.mtimeMs)
456
+ .catch(() => null);
457
+ const identity: SessionIdentity = {
458
+ cwd: null,
459
+ firstUserText: null,
460
+ gitBranch: null,
461
+ model: null,
462
+ sessionId: path.basename(file.filePath, '.jsonl'),
463
+ title: null,
464
+ version: null,
465
+ };
466
+ const stats = createEmptyStats();
467
+ const timeline = { firstMs: null as number | null, lastMs: null as number | null };
468
+ const entries: ClaudeCodeTranscriptEntry[] = [];
469
+ const rawEvents: Record<string, JsonValue>[] = [];
470
+
471
+ for await (const raw of readJsonlObjects(file.filePath)) {
472
+ rawEvents.push(raw);
473
+ updateTimeline(timeline, raw);
474
+ updateIdentityFromRaw(identity, raw);
475
+
476
+ const entry = parseTranscriptEntry(raw);
477
+ if (!entry) {
478
+ continue;
479
+ }
480
+
481
+ entries.push(entry);
482
+ updateStatsFromEntry(stats, entry);
483
+ updateIdentityFromEntry(identity, entry);
484
+ }
485
+
486
+ const session = toSessionSummary(file, identity, stats, toTimeline(timeline, fallbackMtimeMs));
487
+ return {
488
+ transcript: {
489
+ entries,
490
+ rawEvents,
491
+ renderablePartCount: stats.renderablePartCount,
492
+ session,
493
+ },
494
+ };
495
+ };
496
+
497
+ const listTranscriptFilesForProject = async (projectsDir: string, directoryName: string): Promise<TranscriptFile[]> => {
498
+ const projectDir = path.join(projectsDir, directoryName);
499
+ const entries = await readdir(projectDir, { withFileTypes: true }).catch(() => []);
500
+ return entries
501
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
502
+ .map((entry) => ({
503
+ directoryName,
504
+ filePath: path.join(projectDir, entry.name),
505
+ }));
506
+ };
507
+
508
+ const listTranscriptFiles = async (projectsDir: string): Promise<TranscriptFile[]> => {
509
+ if (!(await pathExists(projectsDir))) {
510
+ return [];
511
+ }
512
+
513
+ const projectDirs = (await readdir(projectsDir, { withFileTypes: true }))
514
+ .filter((entry) => entry.isDirectory())
515
+ .map((entry) => entry.name)
516
+ .sort();
517
+ const groupedFiles = await mapWithConcurrency(projectDirs, READ_CONCURRENCY, (directoryName) =>
518
+ listTranscriptFilesForProject(projectsDir, directoryName),
519
+ );
520
+ return groupedFiles.flat();
521
+ };
522
+
523
+ const readTranscriptFiles = async (files: TranscriptFile[]): Promise<ClaudeCodeSessionTranscript[]> => {
524
+ const parsed = await mapWithConcurrency(files, READ_CONCURRENCY, readTranscriptFile);
525
+ return parsed.flatMap((item) => (item ? [item.transcript] : []));
526
+ };
527
+
528
+ const compareNullableMsDesc = (left: number | null, right: number | null): number => {
529
+ return (right ?? 0) - (left ?? 0);
530
+ };
531
+
532
+ const toWorkspaceGroup = (directoryName: string, sessions: ClaudeCodeSessionSummary[]): ClaudeCodeWorkspaceGroup => {
533
+ const worktree = sessions[0]?.worktree ?? decodeWorktreeFromDirectoryName(directoryName);
534
+ const lastActiveAtMs = sessions.reduce<number | null>((latest, session) => {
535
+ if (session.lastActiveAtMs === null) {
536
+ return latest;
537
+ }
538
+
539
+ return latest === null ? session.lastActiveAtMs : Math.max(latest, session.lastActiveAtMs);
540
+ }, null);
541
+
542
+ return {
543
+ assistantMessageCount: sessions.reduce((total, session) => total + session.assistantMessageCount, 0),
544
+ directoryName,
545
+ key: getWorkspaceKey(directoryName),
546
+ label: getWorkspaceLabel(worktree),
547
+ lastActiveAtIso: toIso(lastActiveAtMs),
548
+ lastActiveAtMs,
549
+ messageCount: sessions.reduce((total, session) => total + session.messageCount, 0),
550
+ sessionCount: sessions.length,
551
+ toolCallCount: sessions.reduce((total, session) => total + session.toolCallCount, 0),
552
+ toolResultCount: sessions.reduce((total, session) => total + session.toolResultCount, 0),
553
+ uri: getWorkspaceUri(worktree),
554
+ userMessageCount: sessions.reduce((total, session) => total + session.userMessageCount, 0),
555
+ worktree,
556
+ };
557
+ };
558
+
559
+ export const listClaudeCodeWorkspaceGroups = async (
560
+ projectsDir = resolveClaudeCodeProjectsDir(),
561
+ ): Promise<ClaudeCodeWorkspaceGroup[]> => {
562
+ const files = await listTranscriptFiles(projectsDir);
563
+ const transcripts = await readTranscriptFiles(files);
564
+ const sessionsByDirectory = new Map<string, ClaudeCodeSessionSummary[]>();
565
+
566
+ for (const transcript of transcripts) {
567
+ const directoryName = getDirectoryNameFromWorkspaceKey(transcript.session.workspaceKey);
568
+ if (!directoryName) {
569
+ continue;
570
+ }
571
+
572
+ const sessions = sessionsByDirectory.get(directoryName) ?? [];
573
+ sessions.push(transcript.session);
574
+ sessionsByDirectory.set(directoryName, sessions);
575
+ }
576
+
577
+ return [...sessionsByDirectory.entries()]
578
+ .map(([directoryName, sessions]) => toWorkspaceGroup(directoryName, sessions))
579
+ .sort(
580
+ (left, right) =>
581
+ compareNullableMsDesc(left.lastActiveAtMs, right.lastActiveAtMs) ||
582
+ left.worktree.localeCompare(right.worktree),
583
+ );
584
+ };
585
+
586
+ const claudeCodeWorkspaceMatchesQuery = (workspace: ClaudeCodeWorkspaceGroup, query: string): boolean => {
587
+ const raw = query.trim();
588
+ if (!raw) {
589
+ return true;
590
+ }
591
+
592
+ const lowered = raw.toLowerCase();
593
+ if (
594
+ workspace.key.toLowerCase() === lowered ||
595
+ workspace.directoryName.toLowerCase() === lowered ||
596
+ workspace.label.toLowerCase() === lowered
597
+ ) {
598
+ return true;
599
+ }
600
+
601
+ if (isWorkspacePathQuery(raw)) {
602
+ return workspacePathMatchesQuery(workspace.worktree, raw);
603
+ }
604
+
605
+ return getPortablePathBasename(workspace.worktree).toLowerCase() === lowered;
606
+ };
607
+
608
+ export const findClaudeCodeWorkspaceGroups = (
609
+ groups: ClaudeCodeWorkspaceGroup[],
610
+ query: string,
611
+ ): ClaudeCodeWorkspaceGroup[] => {
612
+ return groups.filter((group) => claudeCodeWorkspaceMatchesQuery(group, query));
613
+ };
614
+
615
+ const sortSessions = (sessions: ClaudeCodeSessionSummary[]): ClaudeCodeSessionSummary[] => {
616
+ return [...sessions].sort(
617
+ (left, right) =>
618
+ compareNullableMsDesc(left.lastActiveAtMs, right.lastActiveAtMs) || left.title.localeCompare(right.title),
619
+ );
620
+ };
621
+
622
+ export const listClaudeCodeSessionsForGroup = async (
623
+ workspaceKey: string,
624
+ projectsDir = resolveClaudeCodeProjectsDir(),
625
+ ): Promise<ClaudeCodeSessionSummary[]> => {
626
+ const directoryName = getDirectoryNameFromWorkspaceKey(workspaceKey);
627
+ if (!directoryName || !(await pathExists(projectsDir))) {
628
+ return [];
629
+ }
630
+
631
+ const files = await listTranscriptFilesForProject(projectsDir, directoryName);
632
+ const transcripts = await readTranscriptFiles(files);
633
+ return sortSessions(transcripts.map((transcript) => transcript.session));
634
+ };
635
+
636
+ const locateSessionFile = async (projectsDir: string, sessionId: string): Promise<TranscriptFile | null> => {
637
+ const files = await listTranscriptFiles(projectsDir);
638
+ return files.find((file) => path.basename(file.filePath, '.jsonl') === sessionId) ?? null;
639
+ };
640
+
641
+ export const readClaudeCodeSessionTranscript = async (
642
+ projectsDir: string,
643
+ sessionId: string,
644
+ ): Promise<ClaudeCodeSessionTranscript | null> => {
645
+ if (!(await pathExists(projectsDir))) {
646
+ return null;
647
+ }
648
+
649
+ const file = await locateSessionFile(projectsDir, sessionId);
650
+ if (!file) {
651
+ return null;
652
+ }
653
+
654
+ return (await readTranscriptFile(file))?.transcript ?? null;
655
+ };