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
@@ -0,0 +1,110 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import type { ExportFormat, JsonValue } from './shared';
4
+
5
+ export const getDefaultGrokHome = (_env: NodeJS.ProcessEnv = process.env, homeDir = os.homedir()): string => {
6
+ return path.join(homeDir, '.grok');
7
+ };
8
+
9
+ export const DEFAULT_GROK_HOME = getDefaultGrokHome();
10
+
11
+ export const resolveGrokHome = (): string => {
12
+ const configured = process.env.SPIRACHA_GROK_HOME?.trim() || process.env.SPIRACHA_GROK_DIR?.trim();
13
+ return configured ? configured : DEFAULT_GROK_HOME;
14
+ };
15
+
16
+ export const resolveGrokSessionsDir = (): string => {
17
+ const configured = process.env.SPIRACHA_GROK_SESSIONS_DIR?.trim();
18
+ return configured ? configured : path.join(resolveGrokHome(), 'sessions');
19
+ };
20
+
21
+ export type GrokWorkspaceGroup = {
22
+ assistantMessageCount: number;
23
+ chatMessageCount: number;
24
+ directoryName: string;
25
+ key: string;
26
+ label: string;
27
+ lastActiveAtIso: string | null;
28
+ lastActiveAtMs: number | null;
29
+ messageCount: number;
30
+ reasoningCount: number;
31
+ sessionCount: number;
32
+ toolCallCount: number;
33
+ toolResultCount: number;
34
+ uri: string;
35
+ userMessageCount: number;
36
+ worktree: string;
37
+ };
38
+
39
+ export type GrokSessionSummary = {
40
+ agentName: string | null;
41
+ assistantMessageCount: number;
42
+ chatHistoryPath: string;
43
+ chatMessageCount: number;
44
+ createdAtIso: string | null;
45
+ createdAtMs: number | null;
46
+ currentModelId: string | null;
47
+ cwd: string | null;
48
+ gitBranch: string | null;
49
+ gitRemotes: string[];
50
+ gitRootDir: string | null;
51
+ headCommit: string | null;
52
+ lastActiveAtIso: string | null;
53
+ lastActiveAtMs: number | null;
54
+ messageCount: number;
55
+ modelLabel: string | null;
56
+ reasoningCount: number;
57
+ renderablePartCount: number;
58
+ sandboxProfile: string | null;
59
+ sessionDir: string;
60
+ sessionId: string;
61
+ summaryPath: string;
62
+ title: string;
63
+ toolCallCount: number;
64
+ toolResultCount: number;
65
+ updatesPath: string | null;
66
+ userMessageCount: number;
67
+ workspaceKey: string;
68
+ workspaceLabel: string;
69
+ worktree: string;
70
+ };
71
+
72
+ export type GrokPartType = 'reasoning' | 'text' | 'tool_call' | 'tool_result' | 'unknown';
73
+
74
+ export type GrokTranscriptPart = {
75
+ argumentsText?: string | null;
76
+ outputText?: string | null;
77
+ partId: string;
78
+ raw: Record<string, JsonValue>;
79
+ text?: string;
80
+ toolCallId?: string | null;
81
+ toolName?: string | null;
82
+ type: GrokPartType;
83
+ };
84
+
85
+ export type GrokTranscriptEntry = {
86
+ createdAtMs: number | null;
87
+ entryId: string;
88
+ modelFingerprint?: string | null;
89
+ modelId?: string | null;
90
+ parts: GrokTranscriptPart[];
91
+ raw: Record<string, JsonValue>;
92
+ role: string;
93
+ timestamp: string | null;
94
+ type: string;
95
+ };
96
+
97
+ export type GrokSessionTranscript = {
98
+ entries: GrokTranscriptEntry[];
99
+ rawEvents: Record<string, JsonValue>[];
100
+ rawPayloadsOmitted?: boolean;
101
+ renderablePartCount: number;
102
+ session: GrokSessionSummary;
103
+ };
104
+
105
+ export type GrokExportOptions = {
106
+ includeCommentary: boolean;
107
+ includeMetadata: boolean;
108
+ includeTools: boolean;
109
+ outputFormat: ExportFormat;
110
+ };
@@ -0,0 +1,61 @@
1
+ import type { GrokTranscriptEntry, GrokTranscriptPart } from './grok-exporter-types';
2
+
3
+ export type GrokAssistantMessagePhase = 'commentary' | 'final_answer' | null;
4
+
5
+ const isGrokCommentaryEnvelope = (entry: GrokTranscriptEntry, part: GrokTranscriptPart): boolean => {
6
+ const text = part.text?.trim() ?? '';
7
+ return entry.role === 'system' || text.startsWith('<user_info>') || text.startsWith('<system-reminder>');
8
+ };
9
+
10
+ export const getFinalGrokAssistantTextPartIds = (entries: GrokTranscriptEntry[]): Set<string> => {
11
+ const finalPartIds = new Set<string>();
12
+ let latestAssistantTextPartId: string | null = null;
13
+
14
+ const flushAssistantRun = () => {
15
+ if (latestAssistantTextPartId) {
16
+ finalPartIds.add(latestAssistantTextPartId);
17
+ latestAssistantTextPartId = null;
18
+ }
19
+ };
20
+
21
+ for (const entry of entries) {
22
+ if (entry.role === 'user') {
23
+ flushAssistantRun();
24
+ continue;
25
+ }
26
+
27
+ if (entry.role !== 'assistant') {
28
+ continue;
29
+ }
30
+
31
+ for (const part of entry.parts) {
32
+ if (part.type === 'tool_call') {
33
+ latestAssistantTextPartId = null;
34
+ continue;
35
+ }
36
+
37
+ if (part.type === 'text' && part.text?.trim()) {
38
+ latestAssistantTextPartId = part.partId;
39
+ }
40
+ }
41
+ }
42
+
43
+ flushAssistantRun();
44
+ return finalPartIds;
45
+ };
46
+
47
+ export const getGrokTextPartPhase = (
48
+ entry: GrokTranscriptEntry,
49
+ part: GrokTranscriptPart,
50
+ finalTextPartIds: Set<string>,
51
+ ): GrokAssistantMessagePhase => {
52
+ if (isGrokCommentaryEnvelope(entry, part)) {
53
+ return 'commentary';
54
+ }
55
+
56
+ if (entry.role !== 'assistant') {
57
+ return null;
58
+ }
59
+
60
+ return finalTextPartIds.has(part.partId) ? 'final_answer' : 'commentary';
61
+ };
@@ -0,0 +1,167 @@
1
+ import type {
2
+ GrokExportOptions,
3
+ GrokSessionSummary,
4
+ GrokSessionTranscript,
5
+ GrokTranscriptEntry,
6
+ GrokTranscriptPart,
7
+ } from './grok-exporter-types';
8
+ import { getFinalGrokAssistantTextPartIds, getGrokTextPartPhase } from './grok-transcript-phase';
9
+ import {
10
+ cleanExtractedText,
11
+ cleanInlineTitle,
12
+ formatInlineLiteral,
13
+ type MetadataEntry,
14
+ renderCodeBlock,
15
+ renderDocumentTitle,
16
+ renderMetadataBlock,
17
+ renderSection,
18
+ } from './shared';
19
+
20
+ const TOOL_OUTPUT_PREVIEW_LIMIT = 4000;
21
+
22
+ const getSessionTitle = (session: GrokSessionSummary): string => {
23
+ return cleanInlineTitle(session.title || session.sessionId);
24
+ };
25
+
26
+ const buildMetadataEntries = (session: GrokSessionSummary): MetadataEntry[] => [
27
+ { key: 'exported_from', value: 'grok_local_session' },
28
+ { key: 'session_id', value: session.sessionId },
29
+ { key: 'title', value: session.title },
30
+ { key: 'source_transcript_path', value: session.chatHistoryPath },
31
+ { key: 'workspace_key', value: session.workspaceKey },
32
+ { key: 'worktree', value: session.worktree },
33
+ { key: 'cwd', value: session.cwd },
34
+ { key: 'model', value: session.currentModelId },
35
+ { key: 'model_label', value: session.modelLabel },
36
+ { key: 'agent_name', value: session.agentName },
37
+ { key: 'git_branch', value: session.gitBranch },
38
+ { key: 'created_at_iso', value: session.createdAtIso },
39
+ { key: 'last_active_at_iso', value: session.lastActiveAtIso },
40
+ { key: 'message_count', value: session.messageCount },
41
+ { key: 'tool_call_count', value: session.toolCallCount },
42
+ { key: 'tool_result_count', value: session.toolResultCount },
43
+ ];
44
+
45
+ const roleTitle = (role: string): string => {
46
+ if (role === 'assistant') {
47
+ return 'Assistant';
48
+ }
49
+
50
+ if (role === 'user') {
51
+ return 'User';
52
+ }
53
+
54
+ if (role === 'system') {
55
+ return 'System';
56
+ }
57
+
58
+ if (role === 'tool') {
59
+ return 'Tool';
60
+ }
61
+
62
+ return role ? cleanInlineTitle(role) : 'Message';
63
+ };
64
+
65
+ const truncateOutput = (text: string): string => {
66
+ if (text.length <= TOOL_OUTPUT_PREVIEW_LIMIT) {
67
+ return text;
68
+ }
69
+
70
+ return `${text.slice(0, TOOL_OUTPUT_PREVIEW_LIMIT)}\n... (truncated)`;
71
+ };
72
+
73
+ const renderTextPart = (entry: GrokTranscriptEntry, part: GrokTranscriptPart, options: GrokExportOptions): string => {
74
+ const text = cleanExtractedText(part.text ?? '').trim();
75
+ return text ? renderSection(roleTitle(entry.role), text, options.outputFormat) : '';
76
+ };
77
+
78
+ const renderReasoningPart = (part: GrokTranscriptPart, options: GrokExportOptions): string => {
79
+ if (!options.includeCommentary) {
80
+ return '';
81
+ }
82
+
83
+ const text = cleanExtractedText(part.text ?? '').trim();
84
+ return text ? renderSection('Reasoning', text, options.outputFormat) : '';
85
+ };
86
+
87
+ const renderToolCallPart = (part: GrokTranscriptPart, options: GrokExportOptions): string => {
88
+ if (!options.includeTools) {
89
+ return '';
90
+ }
91
+
92
+ const toolName = part.toolName ?? 'unknown';
93
+ const lines = [`Tool: ${formatInlineLiteral(toolName, options.outputFormat)}`];
94
+ if (part.toolCallId) {
95
+ lines.push(`Call ID: ${part.toolCallId}`);
96
+ }
97
+ if (part.argumentsText?.trim()) {
98
+ lines.push('', 'Input:', '', renderCodeBlock(part.argumentsText.trim(), options.outputFormat));
99
+ }
100
+
101
+ return renderSection('Tool Call', lines.join('\n'), options.outputFormat);
102
+ };
103
+
104
+ const renderToolResultPart = (part: GrokTranscriptPart, options: GrokExportOptions): string => {
105
+ if (!options.includeTools) {
106
+ return '';
107
+ }
108
+
109
+ const outputText = part.outputText?.trim();
110
+ if (!outputText) {
111
+ return '';
112
+ }
113
+
114
+ const lines: string[] = [];
115
+ if (part.toolCallId) {
116
+ lines.push(`Call ID: ${part.toolCallId}`, '');
117
+ }
118
+ lines.push(renderCodeBlock(truncateOutput(outputText), options.outputFormat));
119
+ return renderSection('Tool Output', lines.join('\n'), options.outputFormat);
120
+ };
121
+
122
+ const renderPart = (
123
+ entry: GrokTranscriptEntry,
124
+ part: GrokTranscriptPart,
125
+ options: GrokExportOptions,
126
+ finalAssistantTextPartIds: Set<string>,
127
+ ): string => {
128
+ switch (part.type) {
129
+ case 'text':
130
+ if (
131
+ getGrokTextPartPhase(entry, part, finalAssistantTextPartIds) === 'commentary' &&
132
+ !options.includeCommentary
133
+ ) {
134
+ return '';
135
+ }
136
+ return renderTextPart(entry, part, options);
137
+ case 'reasoning':
138
+ return renderReasoningPart(part, options);
139
+ case 'tool_call':
140
+ return renderToolCallPart(part, options);
141
+ case 'tool_result':
142
+ return renderToolResultPart(part, options);
143
+ case 'unknown':
144
+ return '';
145
+ }
146
+ };
147
+
148
+ export const renderGrokTranscript = (transcript: GrokSessionTranscript, options: GrokExportOptions): string | null => {
149
+ const finalAssistantTextPartIds = getFinalGrokAssistantTextPartIds(transcript.entries);
150
+ const sections = transcript.entries.flatMap((entry) =>
151
+ entry.parts.map((part) => renderPart(entry, part, options, finalAssistantTextPartIds)).filter(Boolean),
152
+ );
153
+ if (sections.length === 0) {
154
+ return null;
155
+ }
156
+
157
+ const parts = [
158
+ renderDocumentTitle(getSessionTitle(transcript.session), options.outputFormat),
159
+ '',
160
+ options.includeMetadata
161
+ ? renderMetadataBlock(buildMetadataEntries(transcript.session), options.outputFormat)
162
+ : '',
163
+ ...sections,
164
+ ].filter(Boolean);
165
+
166
+ return `${parts.join('\n').trimEnd()}\n`;
167
+ };
@@ -1,7 +1,7 @@
1
- import { createHash } from 'node:crypto';
2
- import { readdir, stat } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readdir, rename, rm, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { mapWithConcurrency } from './concurrency';
4
+ import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
5
5
  import {
6
6
  getDefaultKiroDataDir,
7
7
  type KiroSessionSummary,
@@ -11,21 +11,26 @@ import {
11
11
  type KiroWorkspaceGroup,
12
12
  resolveKiroWorkspaceSessionsDir,
13
13
  } from './kiro-exporter-types';
14
+ import { getPortablePathBasename } from './portable-path';
14
15
  import {
15
16
  asObject,
16
17
  asString,
17
18
  cleanExtractedText,
18
19
  cleanInlineTitle,
19
- getPortablePathBasename,
20
20
  isWorkspacePathQuery,
21
21
  type JsonValue,
22
+ readDirectoryEntriesIfExists,
22
23
  workspacePathMatchesQuery,
23
24
  } from './shared';
24
25
 
25
26
  export { getDefaultKiroDataDir, resolveKiroWorkspaceSessionsDir };
26
27
 
27
28
  const READ_CONCURRENCY = 8;
29
+ const DELETE_CONCURRENCY = 1;
30
+ const EXECUTION_CACHE_TTL_MS = 1_000;
28
31
  const WORKSPACE_KEY_PREFIX = 'workspace:';
32
+ const kiroDeleteLimiter = createConcurrencyLimiter(DELETE_CONCURRENCY);
33
+ const executionFilesCache = new Map<string, { expiresAtMs: number; files: KiroExecutionFile[] }>();
29
34
 
30
35
  type KiroSessionIndexEntry = {
31
36
  createdAtMs: number | null;
@@ -45,6 +50,11 @@ type KiroExecutionFile = {
45
50
  raw: Record<string, JsonValue>;
46
51
  };
47
52
 
53
+ export type DeleteKiroSessionResult = {
54
+ deletedFiles: string[];
55
+ deletedSessionIds: string[];
56
+ };
57
+
48
58
  type ReadSessionFileOptions = {
49
59
  includeExecutions?: boolean;
50
60
  sessionsDir: string;
@@ -82,19 +92,23 @@ const toIso = (value: number | null): string | null => {
82
92
  return value === null ? null : new Date(value).toISOString();
83
93
  };
84
94
 
95
+ const parseTimestampStringMs = (value: string): number | null => {
96
+ const numeric = Number(value);
97
+ if (Number.isFinite(numeric)) {
98
+ return numeric > 0 && numeric < 10_000_000_000 ? numeric * 1000 : numeric;
99
+ }
100
+
101
+ const parsed = Date.parse(value);
102
+ return Number.isFinite(parsed) ? parsed : null;
103
+ };
104
+
85
105
  const parseTimestampMs = (value: JsonValue | undefined): number | null => {
86
106
  if (typeof value === 'number' && Number.isFinite(value)) {
87
107
  return value > 0 && value < 10_000_000_000 ? value * 1000 : value;
88
108
  }
89
109
 
90
110
  if (typeof value === 'string') {
91
- const numeric = Number(value);
92
- if (Number.isFinite(numeric)) {
93
- return numeric > 0 && numeric < 10_000_000_000 ? numeric * 1000 : numeric;
94
- }
95
-
96
- const parsed = Date.parse(value);
97
- return Number.isFinite(parsed) ? parsed : null;
111
+ return value.trim() ? parseTimestampStringMs(value) : null;
98
112
  }
99
113
 
100
114
  return null;
@@ -283,7 +297,9 @@ const parseHistoryEntry = (raw: Record<string, JsonValue>, index: number): KiroT
283
297
  promptLogCount: getPromptLogCount(raw),
284
298
  raw,
285
299
  role: asString(message.role ?? null) ?? 'message',
286
- timestamp: null,
300
+ timestamp: toIso(
301
+ parseTimestampMs(raw.timestamp ?? raw.createdAt ?? raw.created_at ?? message.timestamp ?? null),
302
+ ),
287
303
  };
288
304
  };
289
305
 
@@ -439,7 +455,7 @@ const getKiroWorkspaceHash = (workspacePath: string): string => {
439
455
  };
440
456
 
441
457
  const listFilesRecursively = async (root: string, maxDepth = 3): Promise<string[]> => {
442
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
458
+ const entries = await readDirectoryEntriesIfExists(root);
443
459
  const files: string[] = [];
444
460
 
445
461
  for (const entry of entries) {
@@ -467,18 +483,31 @@ const listExecutionFilesForSession = async (
467
483
  return [];
468
484
  }
469
485
 
470
- const files = await listFilesRecursively(workspaceExecutionRoot);
471
- const executions = await mapWithConcurrency(files, READ_CONCURRENCY, async (filePath) => {
472
- const raw = await readJsonObject(filePath);
473
- return asString(raw?.chatSessionId ?? null) === sessionId && raw ? { filePath, raw } : null;
474
- });
486
+ const cached = executionFilesCache.get(workspaceExecutionRoot);
487
+ let executions = cached && cached.expiresAtMs > Date.now() ? cached.files : null;
488
+ if (!executions) {
489
+ const files = await listFilesRecursively(workspaceExecutionRoot);
490
+ executions = (
491
+ await mapWithConcurrency(files, READ_CONCURRENCY, async (filePath) => {
492
+ const raw = await readJsonObject(filePath);
493
+ return raw ? { filePath, raw } : null;
494
+ })
495
+ ).flatMap((execution) => (execution ? [execution] : []));
496
+ executionFilesCache.set(workspaceExecutionRoot, {
497
+ expiresAtMs: Date.now() + EXECUTION_CACHE_TTL_MS,
498
+ files: executions,
499
+ });
500
+ }
475
501
 
476
- return executions.flatMap((execution) => (execution ? [execution] : []));
502
+ return executions.filter((execution) => asString(execution.raw.chatSessionId ?? null) === sessionId);
477
503
  };
478
504
 
479
505
  const getActionTimestamp = (action: Record<string, JsonValue>, execution: Record<string, JsonValue>): string | null => {
480
506
  return toIso(
481
- parseTimestampMs(action.emittedAt) ?? parseTimestampMs(action.endTime) ?? parseTimestampMs(execution.endTime),
507
+ parseTimestampMs(action.emittedAt) ??
508
+ parseTimestampMs(action.endTime) ??
509
+ parseTimestampMs(execution.endTime) ??
510
+ parseTimestampMs(execution.startTime),
482
511
  );
483
512
  };
484
513
 
@@ -589,6 +618,7 @@ const parseExecutionActionMessageEntry = (
589
618
  raw: {
590
619
  ...action,
591
620
  executionFilePath: execution.filePath,
621
+ executionStartTime: execution.raw.startTime ?? null,
592
622
  },
593
623
  role: 'assistant',
594
624
  timestamp: getActionTimestamp(action, execution.raw),
@@ -628,6 +658,7 @@ const parseExecutionActionToolEntry = (
628
658
  raw: {
629
659
  ...action,
630
660
  executionFilePath: execution.filePath,
661
+ executionStartTime: execution.raw.startTime ?? null,
631
662
  },
632
663
  role: 'tool',
633
664
  timestamp: getActionTimestamp(action, execution.raw),
@@ -673,43 +704,87 @@ const readExecutionEntries = async (
673
704
  return executions.sort(compareExecutionFiles).flatMap(parseExecutionEntries);
674
705
  };
675
706
 
676
- const getVisibleEntries = (
677
- historyEntries: KiroTranscriptEntry[],
678
- executionEntries: KiroTranscriptEntry[],
679
- ): KiroTranscriptEntry[] => {
680
- if (executionEntries.length === 0) {
681
- return historyEntries;
682
- }
683
-
684
- const executionEntriesById = new Map<string, KiroTranscriptEntry[]>();
707
+ const groupExecutionEntriesById = (executionEntries: KiroTranscriptEntry[]) => {
708
+ const entriesById = new Map<string, KiroTranscriptEntry[]>();
685
709
  for (const entry of executionEntries) {
686
710
  if (!entry.executionId) {
687
711
  continue;
688
712
  }
689
713
 
690
- const entries = executionEntriesById.get(entry.executionId) ?? [];
714
+ const entries = entriesById.get(entry.executionId) ?? [];
691
715
  entries.push(entry);
692
- executionEntriesById.set(entry.executionId, entries);
716
+ entriesById.set(entry.executionId, entries);
693
717
  }
718
+ return entriesById;
719
+ };
694
720
 
721
+ const replaceExecutionPlaceholders = (
722
+ historyEntries: KiroTranscriptEntry[],
723
+ executionEntriesById: Map<string, KiroTranscriptEntry[]>,
724
+ ) => {
695
725
  const usedExecutionIds = new Set<string>();
696
726
  const visibleEntries: KiroTranscriptEntry[] = [];
697
727
  for (const entry of historyEntries) {
698
728
  const matchingExecutionEntries = entry.executionId ? executionEntriesById.get(entry.executionId) : undefined;
699
- if (isAssistantPlaceholderEntry(entry)) {
700
- if (matchingExecutionEntries) {
701
- visibleEntries.push(...matchingExecutionEntries);
702
- usedExecutionIds.add(entry.executionId!);
703
- }
729
+ if (!isAssistantPlaceholderEntry(entry) || !matchingExecutionEntries) {
730
+ visibleEntries.push(entry);
704
731
  continue;
705
732
  }
706
733
 
707
- visibleEntries.push(entry);
734
+ visibleEntries.push(...matchingExecutionEntries);
735
+ usedExecutionIds.add(entry.executionId!);
708
736
  }
737
+ return { usedExecutionIds, visibleEntries };
738
+ };
709
739
 
710
- visibleEntries.push(
711
- ...executionEntries.filter((entry) => !entry.executionId || !usedExecutionIds.has(entry.executionId)),
712
- );
740
+ const groupUnmatchedExecutionEntries = (executionEntries: KiroTranscriptEntry[], usedExecutionIds: Set<string>) => {
741
+ const groups = new Map<string, KiroTranscriptEntry[]>();
742
+ for (const entry of executionEntries) {
743
+ if (entry.executionId && usedExecutionIds.has(entry.executionId)) {
744
+ continue;
745
+ }
746
+
747
+ const groupKey = entry.executionId ?? asString(entry.raw.executionFilePath ?? null) ?? entry.entryId;
748
+ const group = groups.get(groupKey) ?? [];
749
+ group.push(entry);
750
+ groups.set(groupKey, group);
751
+ }
752
+ return groups.values();
753
+ };
754
+
755
+ const insertExecutionGroupByStartTime = (
756
+ visibleEntries: KiroTranscriptEntry[],
757
+ executionGroup: KiroTranscriptEntry[],
758
+ ) => {
759
+ const firstEntry = executionGroup[0]!;
760
+ const executionTime = parseTimestampMs(firstEntry.raw.executionStartTime) ?? parseTimestampMs(firstEntry.timestamp);
761
+ const insertionIndex =
762
+ executionTime === null
763
+ ? -1
764
+ : visibleEntries.findIndex((entry) => {
765
+ const entryTime = parseTimestampMs(entry.timestamp);
766
+ return entryTime !== null && entryTime > executionTime;
767
+ });
768
+ if (insertionIndex < 0) {
769
+ visibleEntries.push(...executionGroup);
770
+ } else {
771
+ visibleEntries.splice(insertionIndex, 0, ...executionGroup);
772
+ }
773
+ };
774
+
775
+ const getVisibleEntries = (
776
+ historyEntries: KiroTranscriptEntry[],
777
+ executionEntries: KiroTranscriptEntry[],
778
+ ): KiroTranscriptEntry[] => {
779
+ if (executionEntries.length === 0) {
780
+ return historyEntries;
781
+ }
782
+
783
+ const executionEntriesById = groupExecutionEntriesById(executionEntries);
784
+ const { usedExecutionIds, visibleEntries } = replaceExecutionPlaceholders(historyEntries, executionEntriesById);
785
+ for (const executionGroup of groupUnmatchedExecutionEntries(executionEntries, usedExecutionIds)) {
786
+ insertExecutionGroupByStartTime(visibleEntries, executionGroup);
787
+ }
713
788
  return visibleEntries;
714
789
  };
715
790
 
@@ -782,7 +857,7 @@ const readSessionFile = async (
782
857
  const listSessionFilesForWorkspace = async (sessionsDir: string, directoryName: string): Promise<KiroSessionFile[]> => {
783
858
  const workspaceDir = path.join(sessionsDir, directoryName);
784
859
  const index = await readSessionIndex(workspaceDir);
785
- const entries = await readdir(workspaceDir, { withFileTypes: true }).catch(() => []);
860
+ const entries = await readDirectoryEntriesIfExists(workspaceDir);
786
861
 
787
862
  return entries
788
863
  .filter((entry) => entry.isFile() && entry.name.endsWith('.json') && entry.name !== 'sessions.json')
@@ -815,7 +890,7 @@ const readSessionFiles = async (files: KiroSessionFile[]): Promise<KiroSessionTr
815
890
  const transcripts = await mapWithConcurrency(files, READ_CONCURRENCY, (file) =>
816
891
  readSessionFile(file, { includeExecutions: false, sessionsDir: path.dirname(path.dirname(file.filePath)) }),
817
892
  );
818
- return transcripts.flatMap((transcript) => (transcript ? [transcript] : []));
893
+ return transcripts.flatMap((transcript) => (transcript && transcript.renderablePartCount > 0 ? [transcript] : []));
819
894
  };
820
895
 
821
896
  const compareNullableMsDesc = (left: number | null, right: number | null): number => {
@@ -929,7 +1004,39 @@ export const listKiroSessionsForGroup = async (
929
1004
 
930
1005
  const locateSessionFile = async (sessionsDir: string, sessionId: string): Promise<KiroSessionFile | null> => {
931
1006
  const files = await listSessionFiles(sessionsDir);
932
- return files.find((file) => path.basename(file.filePath, '.json') === sessionId) ?? null;
1007
+ const filenameMatch = files.find((file) => path.basename(file.filePath, '.json') === sessionId);
1008
+ if (filenameMatch) {
1009
+ return filenameMatch;
1010
+ }
1011
+
1012
+ const bodyMatches = await mapWithConcurrency(files, READ_CONCURRENCY, async (file) => {
1013
+ const raw = await readJsonObject(file.filePath);
1014
+ return asString(raw?.sessionId ?? null) === sessionId ? file : null;
1015
+ });
1016
+ return bodyMatches.find((file): file is KiroSessionFile => file !== null) ?? null;
1017
+ };
1018
+
1019
+ const removeKiroSessionIndexEntry = async (workspaceDir: string, sessionId: string): Promise<void> => {
1020
+ const indexPath = path.join(workspaceDir, 'sessions.json');
1021
+ const value = (await Bun.file(indexPath)
1022
+ .json()
1023
+ .catch(() => null)) as JsonValue | null;
1024
+ if (!Array.isArray(value)) {
1025
+ return;
1026
+ }
1027
+
1028
+ const next = value.filter((item) => asString(asObject(item)?.sessionId ?? null) !== sessionId);
1029
+ if (next.length === value.length) {
1030
+ return;
1031
+ }
1032
+
1033
+ const tempPath = `${indexPath}.${randomUUID()}.tmp`;
1034
+ try {
1035
+ await Bun.write(tempPath, JSON.stringify(next, null, 2));
1036
+ await rename(tempPath, indexPath);
1037
+ } finally {
1038
+ await rm(tempPath, { force: true });
1039
+ }
933
1040
  };
934
1041
 
935
1042
  export const readKiroSessionTranscript = async (
@@ -947,3 +1054,40 @@ export const readKiroSessionTranscript = async (
947
1054
 
948
1055
  return readSessionFile(file, { includeExecutions: true, sessionsDir });
949
1056
  };
1057
+
1058
+ export const deleteKiroSession = (sessionsDir: string, sessionId: string): Promise<DeleteKiroSessionResult> => {
1059
+ return kiroDeleteLimiter(async () => {
1060
+ if (!(await pathExists(sessionsDir))) {
1061
+ return { deletedFiles: [], deletedSessionIds: [] };
1062
+ }
1063
+
1064
+ const file = await locateSessionFile(sessionsDir, sessionId);
1065
+ if (!file) {
1066
+ return { deletedFiles: [], deletedSessionIds: [] };
1067
+ }
1068
+
1069
+ const transcript = await readSessionFile(file, { includeExecutions: false, sessionsDir });
1070
+ const executionFiles = transcript
1071
+ ? await listExecutionFilesForSession(
1072
+ getKiroDataDirFromSessionsDir(sessionsDir),
1073
+ sessionId,
1074
+ transcript.session.worktree,
1075
+ )
1076
+ : [];
1077
+ const deletedFiles = [file.filePath, ...executionFiles.map((execution) => execution.filePath)];
1078
+
1079
+ await Promise.all(deletedFiles.map((filePath) => rm(filePath, { force: true })));
1080
+ executionFilesCache.delete(
1081
+ path.join(
1082
+ getKiroDataDirFromSessionsDir(sessionsDir),
1083
+ getKiroWorkspaceHash(transcript?.session.worktree ?? ''),
1084
+ ),
1085
+ );
1086
+ await removeKiroSessionIndexEntry(path.dirname(file.filePath), sessionId);
1087
+
1088
+ return {
1089
+ deletedFiles,
1090
+ deletedSessionIds: [sessionId],
1091
+ };
1092
+ });
1093
+ };