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
@@ -5,9 +5,10 @@ import { getThreadBrowseData } from './codex-browser-db';
5
5
  import type { CodexTranscriptRenderOptions } from './codex-thread-types';
6
6
  import { renderCodexSessionFile, writeCodexSessionFileExport } from './codex-transcript-renderer';
7
7
  import { applyPathTransforms, type PathDisplaySettings } from './path-transforms';
8
- import { type ExportFormat, getPortablePathBasename } from './shared';
9
- import { getExportMimeType, sanitizeExportFileName, zipExportDirectory, zipExportFile } from './ui-export-archive';
8
+ import type { ExportFormat } from './shared';
9
+ import { buildBatchExportBaseName, buildConversationExportBaseName, getExportMimeType } from './ui-export-archive';
10
10
  import { buildUiExportDownloadUrl, ensureUiExportDir } from './ui-export-files';
11
+ import { zipExportDirectory, zipExportFile } from './ui-export-zip';
11
12
 
12
13
  type RenderCodexThreadDownloadInput = {
13
14
  dbPath: string;
@@ -42,31 +43,15 @@ export type CodexThreadDownload =
42
43
 
43
44
  const LARGE_BROWSER_EXPORT_THRESHOLD_BYTES = 128 * 1024 * 1024;
44
45
 
45
- const formatReadableExportDate = (value: number) => {
46
- const date = new Date(value);
47
- const year = date.getUTCFullYear();
48
- const month = String(date.getUTCMonth() + 1).padStart(2, '0');
49
- const day = String(date.getUTCDate()).padStart(2, '0');
50
- const hours = String(date.getUTCHours()).padStart(2, '0');
51
- const minutes = String(date.getUTCMinutes()).padStart(2, '0');
52
- return `${year}-${month}-${day}-${hours}${minutes}`;
53
- };
54
-
55
46
  const buildExportBaseName = (thread: ReturnType<typeof getThreadBrowseData>['thread']) => {
56
- const projectName = sanitizeExportFileName(getPortablePathBasename(thread.cwd) || 'thread') || 'thread';
57
- const timestamp = thread.updated_at_ms ?? thread.updated_at * 1000;
58
- return `${projectName}-${formatReadableExportDate(timestamp)}-${thread.id.slice(0, 8)}`;
59
- };
60
-
61
- const buildBatchExportBaseName = (threads: Array<ReturnType<typeof getThreadBrowseData>['thread']>) => {
62
- const firstThread = threads[0];
63
- if (!firstThread) {
64
- throw new Error('No threads selected for export');
65
- }
66
-
67
- const projectName = sanitizeExportFileName(getPortablePathBasename(firstThread.cwd) || 'threads') || 'threads';
68
- const latestTimestamp = Math.max(...threads.map((thread) => thread.updated_at_ms ?? thread.updated_at * 1000));
69
- return `${projectName}-${formatReadableExportDate(latestTimestamp)}-threads-${threads.length}`;
47
+ return buildConversationExportBaseName(
48
+ {
49
+ cwd: thread.cwd,
50
+ id: thread.id,
51
+ updatedAtMs: thread.updated_at_ms ?? thread.updated_at * 1000,
52
+ },
53
+ 'thread',
54
+ );
70
55
  };
71
56
 
72
57
  const buildUniqueArchivePath = (exportDir: string, exportBaseName: string) => {
@@ -302,7 +287,13 @@ export const renderCodexThreadsDownload = async (
302
287
  const browseEntries = threadIds.map((threadId) => getThreadBrowseData(input.dbPath, threadId));
303
288
  const threads = browseEntries.map((entry) => entry.thread);
304
289
  const exportDir = await resolvePublicExportDir(input.publicExportDir);
305
- const exportBaseName = buildBatchExportBaseName(threads);
290
+ const exportBaseName = buildBatchExportBaseName(
291
+ threads.map((thread) => ({
292
+ cwd: thread.cwd,
293
+ updatedAtMs: thread.updated_at_ms ?? thread.updated_at * 1000,
294
+ })),
295
+ 'threads',
296
+ );
306
297
  const bundleDirectory = await createExportWorkspace(exportDir, exportBaseName);
307
298
  const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
308
299
  const usedBatchEntryBaseNames = new Set<string>();
@@ -14,6 +14,17 @@ export type DynamicToolRow = DynamicToolDefinition & {
14
14
  threadId: string;
15
15
  };
16
16
 
17
+ export type ThreadGoal = {
18
+ createdAtMs: number;
19
+ goalId: string;
20
+ objective: string;
21
+ status: string;
22
+ timeUsedSeconds: number;
23
+ tokenBudget: number | null;
24
+ tokensUsed: number;
25
+ updatedAtMs: number;
26
+ };
27
+
17
28
  export type SessionMetaExtended = SessionMeta & {
18
29
  baseInstructions: JsonValue | null;
19
30
  dynamicTools: DynamicToolDefinition[];
@@ -165,14 +176,20 @@ export type ThreadListEntry = {
165
176
 
166
177
  export type ThreadBrowseData = {
167
178
  dynamicTools: DynamicToolRow[];
179
+ goals: ThreadGoal[];
168
180
  project: string;
169
181
  relations: ThreadRelations;
170
182
  thread: ThreadRow;
171
183
  };
172
184
 
185
+ export type DashboardThreadSummary = Pick<
186
+ ThreadRow,
187
+ 'cwd' | 'id' | 'model' | 'preview' | 'title' | 'tokens_used' | 'updated_at' | 'updated_at_ms'
188
+ >;
189
+
173
190
  export type DashboardRecentThread = {
174
191
  project: string;
175
- thread: ThreadRow;
192
+ thread: DashboardThreadSummary;
176
193
  };
177
194
 
178
195
  export type DashboardSummary = {
@@ -232,6 +249,7 @@ export type CodexAnalyticsSummary = {
232
249
  archivedThreads: number;
233
250
  averageTokensPerThread: number;
234
251
  distinctToolNames: number;
252
+ medianTokensPerThread: number;
235
253
  threadsWithWebSearch: number;
236
254
  totalProjects: number;
237
255
  totalThreads: number;
@@ -240,6 +258,8 @@ export type CodexAnalyticsSummary = {
240
258
 
241
259
  export type CodexAnalytics = {
242
260
  modelsByTokens: ModelTokenSummary[];
261
+ reasoningEfforts: DistributionItem[];
262
+ sources: DistributionItem[];
243
263
  summary: CodexAnalyticsSummary;
244
264
  toolUsage: ToolUsageSummary[];
245
265
  };
@@ -1,24 +1,85 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import type { ParsedCodexTranscript } from './codex-browser-types';
3
+ import type { ParsedCodexTranscript, ThreadTranscriptStats } from './codex-browser-types';
4
4
  import { parseCodexTranscriptFile } from './codex-thread-parser';
5
- import { getFileFingerprint, hashCacheKeyParts, withCachedJson } from './ui-cache';
5
+ import type { CodexTranscriptEventFilters } from './codex-transcript-filter';
6
+ import { shouldShowCodexTranscriptEvent } from './codex-transcript-filter';
7
+ import { runWithTranscriptLoadLimit } from './transcript-load-limiter';
8
+ import { getFileFingerprint, hashCacheKeyPartsIterable, withCachedJson } from './ui-cache';
6
9
 
7
- export const LARGE_THREAD_SIZE_BYTES = 100 * 1024 * 1024;
10
+ // Keep initial thread payloads below sizes that make TanStack Start SSR responses unreliable.
11
+ export const LARGE_THREAD_SIZE_BYTES = 8 * 1024 * 1024;
8
12
  export const LARGE_THREAD_PREVIEW_EVENT_LIMIT = 200;
13
+ const CODEX_TRANSCRIPT_CACHE_VERSION = 'v3';
14
+ const CODEX_TRANSCRIPT_STATS_CACHE_VERSION = 'v1';
15
+ const FILE_STABILITY_ATTEMPTS = 3;
16
+
17
+ type CodexTranscriptStatsLoader = (sessionFile: string) => Promise<ThreadTranscriptStats>;
9
18
 
10
19
  const isMissingFileError = (error: unknown) => {
11
20
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
12
21
  };
13
22
 
23
+ const withStableFileCache = async <T>(
24
+ sessionFile: string,
25
+ keyForFingerprint: (fingerprint: string) => string,
26
+ loader: () => Promise<T>,
27
+ ): Promise<T> => {
28
+ for (let attempt = 0; attempt < FILE_STABILITY_ATTEMPTS; attempt += 1) {
29
+ const fingerprint = await getFileFingerprint(sessionFile);
30
+ const value = await withCachedJson(keyForFingerprint(fingerprint), loader);
31
+ if ((await getFileFingerprint(sessionFile)) === fingerprint) {
32
+ return value;
33
+ }
34
+ }
35
+
36
+ throw new Error(`Codex rollout changed repeatedly while loading: ${sessionFile}`);
37
+ };
38
+
14
39
  export const getCachedParsedCodexTranscript = async (sessionFile: string): Promise<ParsedCodexTranscript> => {
15
- const fingerprint = await getFileFingerprint(sessionFile);
16
- const key = `thread-${hashCacheKeyParts(path.basename(sessionFile), fingerprint)}`;
40
+ return withStableFileCache(
41
+ sessionFile,
42
+ (fingerprint) =>
43
+ `thread-${hashCacheKeyPartsIterable([CODEX_TRANSCRIPT_CACHE_VERSION, path.basename(sessionFile), fingerprint])}`,
44
+ async () =>
45
+ runWithTranscriptLoadLimit(() => parseCodexTranscriptFile(sessionFile), {
46
+ path: sessionFile,
47
+ source: 'codex-full',
48
+ }),
49
+ );
50
+ };
17
51
 
18
- return withCachedJson(key, async () => parseCodexTranscriptFile(sessionFile));
52
+ const loadCodexTranscriptStats: CodexTranscriptStatsLoader = async (sessionFile) => {
53
+ const transcript = await parseCodexTranscriptFile(sessionFile, {
54
+ includeRaw: false,
55
+ maxTurnContexts: 0,
56
+ });
57
+
58
+ return transcript.stats;
59
+ };
60
+
61
+ export const getCachedCodexTranscriptStats = async (
62
+ sessionFile: string,
63
+ loadStats: CodexTranscriptStatsLoader = loadCodexTranscriptStats,
64
+ ): Promise<ThreadTranscriptStats> => {
65
+ return withStableFileCache(
66
+ sessionFile,
67
+ (fingerprint) =>
68
+ `thread-list-stats-${hashCacheKeyPartsIterable([
69
+ CODEX_TRANSCRIPT_STATS_CACHE_VERSION,
70
+ path.basename(sessionFile),
71
+ fingerprint,
72
+ ])}`,
73
+ () =>
74
+ runWithTranscriptLoadLimit(() => loadStats(sessionFile), {
75
+ path: sessionFile,
76
+ source: 'codex-list-stats',
77
+ }),
78
+ );
19
79
  };
20
80
 
21
81
  type CachedThreadTranscriptPreviewOptions = {
82
+ filters?: CodexTranscriptEventFilters;
22
83
  largeTranscriptThresholdBytes?: number;
23
84
  previewEventLimit?: number;
24
85
  };
@@ -53,22 +114,44 @@ export const getCachedThreadTranscriptPreview = async (
53
114
  ): Promise<ParsedCodexTranscript> => {
54
115
  const threshold = options.largeTranscriptThresholdBytes ?? LARGE_THREAD_SIZE_BYTES;
55
116
  const previewEventLimit = options.previewEventLimit ?? LARGE_THREAD_PREVIEW_EVENT_LIMIT;
56
- const fingerprint = await getFileFingerprint(sessionFile);
57
- const { fileSizeBytes, shouldDeferTranscriptLoad } = await getThreadRolloutLoadState(sessionFile, threshold);
58
- const key = `thread-preview-${hashCacheKeyParts(path.basename(sessionFile), fingerprint, String(threshold), String(previewEventLimit))}`;
59
-
60
- return withCachedJson(key, async () => {
61
- if (!shouldDeferTranscriptLoad) {
62
- return parseCodexTranscriptFile(sessionFile, {
63
- sourceFileSizeBytes: fileSizeBytes,
64
- });
65
- }
117
+ const filters = options.filters;
118
+ const filterKey = filters ? JSON.stringify(filters) : 'all';
119
+ return withStableFileCache(
120
+ sessionFile,
121
+ (fingerprint) =>
122
+ `thread-preview-${hashCacheKeyPartsIterable([CODEX_TRANSCRIPT_CACHE_VERSION, path.basename(sessionFile), fingerprint, String(threshold), String(previewEventLimit), filterKey])}`,
123
+ async () => {
124
+ const { fileSizeBytes, shouldDeferTranscriptLoad } = await getThreadRolloutLoadState(
125
+ sessionFile,
126
+ threshold,
127
+ );
128
+ if (!shouldDeferTranscriptLoad) {
129
+ return runWithTranscriptLoadLimit(
130
+ () =>
131
+ parseCodexTranscriptFile(sessionFile, {
132
+ sourceFileSizeBytes: fileSizeBytes,
133
+ }),
134
+ {
135
+ path: sessionFile,
136
+ source: 'codex-preview-full',
137
+ },
138
+ );
139
+ }
66
140
 
67
- return parseCodexTranscriptFile(sessionFile, {
68
- includeRaw: false,
69
- maxEvents: previewEventLimit,
70
- maxTurnContexts: 0,
71
- sourceFileSizeBytes: fileSizeBytes,
72
- });
73
- });
141
+ return runWithTranscriptLoadLimit(
142
+ () =>
143
+ parseCodexTranscriptFile(sessionFile, {
144
+ eventFilter: filters ? (event) => shouldShowCodexTranscriptEvent(event, filters) : undefined,
145
+ includeRaw: false,
146
+ maxTurnContexts: 0,
147
+ sourceFileSizeBytes: fileSizeBytes,
148
+ tailEventLimit: previewEventLimit,
149
+ }),
150
+ {
151
+ path: sessionFile,
152
+ source: 'codex-preview',
153
+ },
154
+ );
155
+ },
156
+ );
74
157
  };
@@ -14,13 +14,29 @@ import type {
14
14
  TurnContextRecord,
15
15
  WebSearchEvent,
16
16
  } from './codex-browser-types';
17
- import { asNumber, asObject, asString, type JsonValue, readJsonlObjects } from './shared';
17
+ import { asNumber, asObject, asString, type JsonValue, readJsonlObjects, stripCodexAppDirectiveLines } from './shared';
18
18
 
19
19
  type ParseCodexTranscriptOptions = {
20
+ eventFilter?: (event: ThreadEvent) => boolean;
20
21
  includeRaw?: boolean;
21
22
  maxEvents?: number;
22
23
  maxTurnContexts?: number;
23
24
  sourceFileSizeBytes?: number | null;
25
+ tailEventLimit?: number;
26
+ };
27
+
28
+ type ParseCodexTranscriptState = {
29
+ events: ThreadEvent[];
30
+ eventFilter: (event: ThreadEvent) => boolean;
31
+ includeRaw: boolean;
32
+ maxEvents: number;
33
+ maxTurnContexts: number;
34
+ sequence: number;
35
+ shouldStop: boolean;
36
+ tailEventCursor: number;
37
+ tailEventLimit: number;
38
+ tailEventsWrapped: boolean;
39
+ turnContexts: TurnContextRecord[];
24
40
  };
25
41
 
26
42
  const createEmptyStats = (): ThreadTranscriptStats => {
@@ -64,45 +80,88 @@ export const parseCodexTranscriptFile = async (
64
80
  const includeRaw = options.includeRaw ?? true;
65
81
  const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
66
82
  const maxTurnContexts = options.maxTurnContexts ?? Number.POSITIVE_INFINITY;
67
- let sequence = 0;
83
+ const tailEventLimit = options.tailEventLimit ?? Number.POSITIVE_INFINITY;
84
+ const eventFilter = options.eventFilter ?? (() => true);
85
+ const state: ParseCodexTranscriptState = {
86
+ eventFilter,
87
+ events,
88
+ includeRaw,
89
+ maxEvents,
90
+ maxTurnContexts,
91
+ sequence: 0,
92
+ shouldStop: false,
93
+ tailEventCursor: 0,
94
+ tailEventLimit,
95
+ tailEventsWrapped: false,
96
+ turnContexts,
97
+ };
68
98
 
69
99
  for await (const parsed of readJsonlObjects(sessionFile)) {
70
100
  captureSessionMeta(parsed, sessionMeta);
71
- const topLevelType = asString(parsed.type);
72
-
73
- if (topLevelType === 'turn_context') {
74
- if (turnContexts.length < maxTurnContexts) {
75
- captureTurnContext(parsed, turnContexts);
76
- }
77
- continue;
101
+ captureTranscriptRecord(parsed, state);
102
+ if (state.shouldStop) {
103
+ break;
78
104
  }
105
+ }
79
106
 
80
- const event = toThreadEvent(parsed, sequence, includeRaw);
81
- if (!event) {
82
- continue;
83
- }
107
+ if (state.tailEventsWrapped && events.length > 0) {
108
+ events.splice(
109
+ 0,
110
+ events.length,
111
+ ...events.slice(state.tailEventCursor),
112
+ ...events.slice(0, state.tailEventCursor),
113
+ );
114
+ }
84
115
 
85
- events.push(event);
116
+ for (const event of events) {
86
117
  updateTranscriptStats(stats, event);
87
- sequence += 1;
88
-
89
- if (events.length >= maxEvents) {
90
- break;
91
- }
92
118
  }
93
119
 
94
120
  return {
95
121
  events,
96
- isPartial: Number.isFinite(maxEvents) || Number.isFinite(maxTurnContexts),
122
+ isPartial:
123
+ Number.isFinite(maxEvents) ||
124
+ Number.isFinite(maxTurnContexts) ||
125
+ Number.isFinite(tailEventLimit) ||
126
+ options.eventFilter !== undefined,
97
127
  rawIncluded: includeRaw,
98
128
  sessionMeta,
99
129
  sourceFileSizeBytes: options.sourceFileSizeBytes ?? null,
100
130
  stats,
101
- statsArePartial: Number.isFinite(maxEvents),
131
+ statsArePartial:
132
+ Number.isFinite(maxEvents) || Number.isFinite(tailEventLimit) || options.eventFilter !== undefined,
102
133
  turnContexts,
103
134
  };
104
135
  };
105
136
 
137
+ const captureTranscriptRecord = (parsed: Record<string, JsonValue>, state: ParseCodexTranscriptState) => {
138
+ if (asString(parsed.type) === 'turn_context') {
139
+ if (state.turnContexts.length < state.maxTurnContexts) {
140
+ captureTurnContext(parsed, state.turnContexts);
141
+ }
142
+ return;
143
+ }
144
+
145
+ const event = parseCodexTranscriptRecord(parsed, state.sequence, state.includeRaw);
146
+ if (!event) {
147
+ return;
148
+ }
149
+
150
+ state.sequence += 1;
151
+ if (!state.eventFilter(event)) {
152
+ return;
153
+ }
154
+
155
+ if (!Number.isFinite(state.tailEventLimit) || state.events.length < state.tailEventLimit) {
156
+ state.events.push(event);
157
+ } else if (state.tailEventLimit > 0) {
158
+ state.events[state.tailEventCursor] = event;
159
+ state.tailEventCursor = (state.tailEventCursor + 1) % state.tailEventLimit;
160
+ state.tailEventsWrapped = true;
161
+ }
162
+ state.shouldStop = state.events.length >= state.maxEvents;
163
+ };
164
+
106
165
  const captureSessionMeta = (parsed: Record<string, JsonValue>, sessionMeta: SessionMetaExtended) => {
107
166
  if (parsed.type !== 'session_meta') {
108
167
  return;
@@ -161,10 +220,10 @@ const captureTurnContext = (parsed: Record<string, JsonValue>, turnContexts: Tur
161
220
  });
162
221
  };
163
222
 
164
- const toThreadEvent = (
223
+ export const parseCodexTranscriptRecord = (
165
224
  parsed: Record<string, JsonValue>,
166
- sequence: number,
167
- includeRaw: boolean,
225
+ sequence = 0,
226
+ includeRaw = false,
168
227
  ): ThreadEvent | null => {
169
228
  const payload = asObject(parsed.payload);
170
229
  if (!payload) {
@@ -192,15 +251,7 @@ const buildEventMessage = (
192
251
  sequence: number,
193
252
  timestamp: string | null,
194
253
  ) => {
195
- if (payloadType === 'task_started') {
196
- return createTaskStartedEvent(payload, raw, sequence, timestamp);
197
- }
198
-
199
- if (payloadType === 'task_complete') {
200
- return createTaskCompleteEvent(payload, raw, sequence, timestamp);
201
- }
202
-
203
- return null;
254
+ return buildSupplementalEvent(payload, payloadType, raw, sequence, timestamp);
204
255
  };
205
256
 
206
257
  const buildResponseItemEvent = (
@@ -222,11 +273,11 @@ const buildResponseItemEvent = (
222
273
  return createAgentMessageEvent(payload, raw, sequence, timestamp);
223
274
  }
224
275
 
225
- if (payloadType === 'function_call') {
226
- return createToolCallEvent(payload, raw, sequence, timestamp);
276
+ if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
277
+ return createToolCallEvent(payload, raw, sequence, timestamp, payloadType === 'custom_tool_call');
227
278
  }
228
279
 
229
- if (payloadType === 'function_call_output') {
280
+ if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
230
281
  return createToolOutputEvent(payload, raw, sequence, timestamp);
231
282
  }
232
283
 
@@ -234,6 +285,16 @@ const buildResponseItemEvent = (
234
285
  return createReasoningEvent(payload, raw, sequence, timestamp);
235
286
  }
236
287
 
288
+ return buildSupplementalEvent(payload, payloadType, raw, sequence, timestamp);
289
+ };
290
+
291
+ const buildSupplementalEvent = (
292
+ payload: Record<string, JsonValue>,
293
+ payloadType: string | null,
294
+ raw: Record<string, JsonValue>,
295
+ sequence: number,
296
+ timestamp: string | null,
297
+ ) => {
237
298
  if (payloadType === 'token_count') {
238
299
  return createTokenCountEvent(payload, raw, sequence, timestamp);
239
300
  }
@@ -261,12 +322,13 @@ const createMessageEvent = (
261
322
  ): MessageEvent | null => {
262
323
  const role = asString(payload.role);
263
324
  const content = payload.content;
264
- if (!role || content === undefined) {
325
+ const text = extractText(content);
326
+ if (!role || content === undefined || !text.trim()) {
265
327
  return null;
266
328
  }
267
329
 
268
330
  return {
269
- isHiddenByDefault: shouldHideTranscriptText(role, extractText(content)),
331
+ isHiddenByDefault: shouldHideCodexTranscriptText(role, text),
270
332
  kind: 'message',
271
333
  memoryCitation: null,
272
334
  model: asString(payload.model),
@@ -274,7 +336,7 @@ const createMessageEvent = (
274
336
  raw,
275
337
  role,
276
338
  sequence,
277
- text: extractText(content),
339
+ text,
278
340
  timestamp,
279
341
  variant: 'message',
280
342
  };
@@ -286,8 +348,9 @@ const createUserMessageEvent = (
286
348
  sequence: number,
287
349
  timestamp: string | null,
288
350
  ): MessageEvent => {
351
+ const text = stripCodexMemoryCitationBlocks(asString(payload.message) ?? '');
289
352
  return {
290
- isHiddenByDefault: shouldHideTranscriptText('user', asString(payload.message)?.trim() ?? ''),
353
+ isHiddenByDefault: shouldHideCodexTranscriptText('user', text),
291
354
  kind: 'message',
292
355
  memoryCitation: null,
293
356
  model: null,
@@ -295,7 +358,7 @@ const createUserMessageEvent = (
295
358
  raw,
296
359
  role: 'user',
297
360
  sequence,
298
- text: asString(payload.message)?.trim() ?? '',
361
+ text,
299
362
  timestamp,
300
363
  variant: 'user_message',
301
364
  };
@@ -307,8 +370,9 @@ const createAgentMessageEvent = (
307
370
  sequence: number,
308
371
  timestamp: string | null,
309
372
  ): MessageEvent => {
373
+ const text = stripCodexMemoryCitationBlocks(asString(payload.message) ?? '');
310
374
  return {
311
- isHiddenByDefault: false,
375
+ isHiddenByDefault: shouldHideCodexTranscriptText('assistant', text),
312
376
  kind: 'message',
313
377
  memoryCitation: payload.memory_citation ?? null,
314
378
  model: asString(payload.model),
@@ -316,7 +380,7 @@ const createAgentMessageEvent = (
316
380
  raw,
317
381
  role: 'assistant',
318
382
  sequence,
319
- text: asString(payload.message)?.trim() ?? '',
383
+ text,
320
384
  timestamp,
321
385
  variant: 'agent_message',
322
386
  };
@@ -327,10 +391,13 @@ const createToolCallEvent = (
327
391
  raw: Record<string, JsonValue>,
328
392
  sequence: number,
329
393
  timestamp: string | null,
394
+ isCustomToolCall: boolean,
330
395
  ): ToolCallEvent => {
331
396
  const name = asString(payload.name) ?? 'unknown';
332
- const argumentsText = asString(payload.arguments);
333
- const parsedArguments = parseExecCommandArguments(argumentsText);
397
+ const argumentsText = asString(isCustomToolCall ? payload.input : payload.arguments);
398
+ const parsedArguments = isCustomToolCall
399
+ ? { argumentsParseFailed: false, cmd: argumentsText, workdir: null }
400
+ : parseExecCommandArguments(argumentsText);
334
401
 
335
402
  return {
336
403
  argumentsParseFailed: parsedArguments.argumentsParseFailed,
@@ -352,7 +419,7 @@ const createToolOutputEvent = (
352
419
  sequence: number,
353
420
  timestamp: string | null,
354
421
  ): ToolOutputEvent => {
355
- const outputText = asString(payload.output) ?? '';
422
+ const outputText = extractToolOutputText(payload.output);
356
423
 
357
424
  return {
358
425
  callId: asString(payload.call_id),
@@ -478,7 +545,7 @@ const updateTranscriptStats = (stats: ThreadTranscriptStats, event: ThreadEvent)
478
545
 
479
546
  if (event.kind === 'tool_call') {
480
547
  stats.toolCallCount += 1;
481
- if (event.name === 'exec_command') {
548
+ if (event.name === 'exec' || event.name === 'exec_command') {
482
549
  stats.execCommandCount += 1;
483
550
  }
484
551
  return;
@@ -546,26 +613,52 @@ const parseExecCommandArguments = (argumentsText: string | null) => {
546
613
  }
547
614
  };
548
615
 
616
+ const extractToolOutputText = (output: JsonValue | undefined): string => {
617
+ if (typeof output === 'string') {
618
+ return output;
619
+ }
620
+
621
+ if (!Array.isArray(output)) {
622
+ return '';
623
+ }
624
+
625
+ return output
626
+ .map((entry) => {
627
+ const contentBlock = asObject(entry);
628
+ return contentBlock ? asString(contentBlock.text) : null;
629
+ })
630
+ .filter((entry): entry is string => entry !== null)
631
+ .join('\n');
632
+ };
633
+
549
634
  const extractText = (content: JsonValue): string => {
550
635
  if (typeof content === 'string') {
551
- return content.trim();
636
+ return stripCodexMemoryCitationBlocks(content);
552
637
  }
553
638
 
554
639
  if (Array.isArray(content)) {
555
- return content
556
- .map((entry) => extractTextPart(entry))
557
- .filter(Boolean)
558
- .join('\n\n')
559
- .trim();
640
+ return stripCodexMemoryCitationBlocks(
641
+ content
642
+ .map((entry) => extractTextPart(entry))
643
+ .filter(Boolean)
644
+ .join('\n\n'),
645
+ );
560
646
  }
561
647
 
562
648
  if (content && typeof content === 'object') {
563
- return asString((content as Record<string, JsonValue>).text)?.trim() ?? '';
649
+ return stripCodexMemoryCitationBlocks(asString((content as Record<string, JsonValue>).text) ?? '');
564
650
  }
565
651
 
566
652
  return '';
567
653
  };
568
654
 
655
+ export const stripCodexMemoryCitationBlocks = (text: string): string => {
656
+ const withoutMemoryCitations = text.includes('<oai-mem-citation>')
657
+ ? text.replace(/<oai-mem-citation>[\s\S]*?<\/oai-mem-citation>/gu, '')
658
+ : text;
659
+ return stripCodexAppDirectiveLines(withoutMemoryCitations);
660
+ };
661
+
569
662
  const extractTextPart = (entry: JsonValue): string => {
570
663
  const objectValue = asObject(entry);
571
664
  if (!objectValue) {
@@ -582,7 +675,7 @@ const extractTextPart = (entry: JsonValue): string => {
582
675
  return text ?? '';
583
676
  };
584
677
 
585
- const shouldHideTranscriptText = (role: string, text: string) => {
678
+ export const shouldHideCodexTranscriptText = (role: string, text: string) => {
586
679
  if (!text) {
587
680
  return true;
588
681
  }
@@ -593,6 +686,7 @@ const shouldHideTranscriptText = (role: string, text: string) => {
593
686
 
594
687
  return (
595
688
  text.startsWith('# AGENTS.md instructions for ') ||
689
+ text.startsWith('AGENTS.md instructions for ') ||
596
690
  text.startsWith('<permissions instructions>') ||
597
691
  text.startsWith('<app-context>') ||
598
692
  text.startsWith('<environment_context>') ||