spiracha 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import type { ClaudeCodeAssistantMessagePhase } from './claude-code-transcript-phase';
3
4
  import type { ExportFormat, JsonValue } from './shared';
4
5
 
5
6
  export type { ClaudeCodeAssistantMessagePhase } from './claude-code-transcript-phase';
@@ -84,6 +85,7 @@ export type ClaudeCodeTranscriptPart = {
84
85
  };
85
86
 
86
87
  export type ClaudeCodeTranscriptEntry = {
88
+ assistantPhase?: ClaudeCodeAssistantMessagePhase | null;
87
89
  cwd: string | null;
88
90
  entryId: string;
89
91
  model?: string | null;
@@ -97,6 +99,9 @@ export type ClaudeCodeTranscriptEntry = {
97
99
 
98
100
  export type ClaudeCodeSessionTranscript = {
99
101
  entries: ClaudeCodeTranscriptEntry[];
102
+ isPartial?: boolean;
103
+ omittedEntryCount?: number;
104
+ rawPayloadsOmitted?: boolean;
100
105
  rawEvents: Record<string, JsonValue>[];
101
106
  renderablePartCount: number;
102
107
  session: ClaudeCodeSessionSummary;
@@ -3,16 +3,39 @@ import type { JsonValue } from './shared';
3
3
  export type ClaudeCodeAssistantMessagePhase = 'commentary' | 'final_answer';
4
4
 
5
5
  type ClaudeCodeAssistantPhaseEntry = {
6
+ assistantPhase?: ClaudeCodeAssistantMessagePhase | null;
7
+ parts?: Array<{
8
+ text?: string;
9
+ type: string;
10
+ }>;
6
11
  raw: Record<string, JsonValue>;
7
12
  role: string;
8
13
  };
9
14
 
15
+ type ClaudeCodeFilterEntry = ClaudeCodeAssistantPhaseEntry & {
16
+ parts: Array<{
17
+ text?: string;
18
+ type: string;
19
+ }>;
20
+ };
21
+
22
+ const SYNTHETIC_USER_TEXT_PREFIXES = [
23
+ '<local-command-caveat>',
24
+ '<local-command-stdout>',
25
+ '<task-notification>',
26
+ '[Request interrupted by user]',
27
+ ] as const;
28
+
10
29
  const asMessageObject = (value: JsonValue | undefined): Record<string, JsonValue> | null => {
11
30
  return typeof value === 'object' && value !== null && !Array.isArray(value)
12
31
  ? (value as Record<string, JsonValue>)
13
32
  : null;
14
33
  };
15
34
 
35
+ export const isClaudeCodeRawFlagEnabled = (value: JsonValue | undefined): boolean => {
36
+ return value === true || value === 'true';
37
+ };
38
+
16
39
  export const getClaudeCodeAssistantMessagePhase = (
17
40
  entry: ClaudeCodeAssistantPhaseEntry,
18
41
  ): ClaudeCodeAssistantMessagePhase | null => {
@@ -20,6 +43,42 @@ export const getClaudeCodeAssistantMessagePhase = (
20
43
  return null;
21
44
  }
22
45
 
46
+ if (entry.assistantPhase) {
47
+ return entry.assistantPhase;
48
+ }
49
+
23
50
  const stopReason = asMessageObject(entry.raw.message)?.stop_reason;
24
- return stopReason === 'tool_use' ? 'commentary' : 'final_answer';
51
+ if (stopReason !== 'tool_use') {
52
+ return 'final_answer';
53
+ }
54
+
55
+ const toolUseIndex = entry.parts?.findIndex((part) => part.type === 'tool_use') ?? -1;
56
+ const hasTextAfterToolUse =
57
+ toolUseIndex >= 0 &&
58
+ entry.parts?.slice(toolUseIndex + 1).some((part) => part.type === 'text' && part.text?.trim()) === true;
59
+ return hasTextAfterToolUse ? 'final_answer' : 'commentary';
60
+ };
61
+
62
+ export const isClaudeCodeSyntheticTranscriptEntry = (entry: ClaudeCodeFilterEntry): boolean => {
63
+ if (
64
+ isClaudeCodeRawFlagEnabled(entry.raw.isApiErrorMessage) ||
65
+ isClaudeCodeRawFlagEnabled(entry.raw.isMeta) ||
66
+ isClaudeCodeRawFlagEnabled(entry.raw.isCompactSummary)
67
+ ) {
68
+ return true;
69
+ }
70
+
71
+ if (asMessageObject(entry.raw.message)?.model === '<synthetic>') {
72
+ return true;
73
+ }
74
+
75
+ if (entry.role !== 'user') {
76
+ return false;
77
+ }
78
+
79
+ const text = entry.parts.find((part) => part.type === 'text')?.text?.trim() ?? '';
80
+ return (
81
+ text.startsWith('<command-name>/compact</command-name>') ||
82
+ SYNTHETIC_USER_TEXT_PREFIXES.some((prefix) => text.startsWith(prefix))
83
+ );
25
84
  };
@@ -5,7 +5,10 @@ import type {
5
5
  ClaudeCodeTranscriptEntry,
6
6
  ClaudeCodeTranscriptPart,
7
7
  } from './claude-code-exporter-types';
8
- import { getClaudeCodeAssistantMessagePhase } from './claude-code-transcript-phase';
8
+ import {
9
+ getClaudeCodeAssistantMessagePhase,
10
+ isClaudeCodeSyntheticTranscriptEntry,
11
+ } from './claude-code-transcript-phase';
9
12
  import {
10
13
  cleanExtractedText,
11
14
  cleanInlineTitle,
@@ -102,7 +105,7 @@ const renderToolResultPart = (part: ClaudeCodeTranscriptPart, options: ClaudeCod
102
105
  return '';
103
106
  }
104
107
 
105
- const outputText = part.outputText?.trim();
108
+ const outputText = (part.outputText ?? '').trim();
106
109
  if (!outputText) {
107
110
  return '';
108
111
  }
@@ -160,9 +163,9 @@ export const renderClaudeCodeTranscript = (
160
163
  transcript: ClaudeCodeSessionTranscript,
161
164
  options: ClaudeCodeExportOptions,
162
165
  ): string | null => {
163
- const sections = transcript.entries.flatMap((entry) =>
164
- entry.parts.map((part) => renderPart(entry, part, options)).filter(Boolean),
165
- );
166
+ const sections = transcript.entries
167
+ .filter((entry) => !isClaudeCodeSyntheticTranscriptEntry(entry))
168
+ .flatMap((entry) => entry.parts.map((part) => renderPart(entry, part, options)).filter(Boolean));
166
169
  if (sections.length === 0) {
167
170
  return null;
168
171
  }
@@ -5,8 +5,14 @@ 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 {
10
+ buildBatchExportBaseName,
11
+ buildConversationExportBaseName,
12
+ getExportMimeType,
13
+ zipExportDirectory,
14
+ zipExportFile,
15
+ } from './ui-export-archive';
10
16
  import { buildUiExportDownloadUrl, ensureUiExportDir } from './ui-export-files';
11
17
 
12
18
  type RenderCodexThreadDownloadInput = {
@@ -42,31 +48,15 @@ export type CodexThreadDownload =
42
48
 
43
49
  const LARGE_BROWSER_EXPORT_THRESHOLD_BYTES = 128 * 1024 * 1024;
44
50
 
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
51
  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}`;
52
+ return buildConversationExportBaseName(
53
+ {
54
+ cwd: thread.cwd,
55
+ id: thread.id,
56
+ updatedAtMs: thread.updated_at_ms ?? thread.updated_at * 1000,
57
+ },
58
+ 'thread',
59
+ );
70
60
  };
71
61
 
72
62
  const buildUniqueArchivePath = (exportDir: string, exportBaseName: string) => {
@@ -302,7 +292,13 @@ export const renderCodexThreadsDownload = async (
302
292
  const browseEntries = threadIds.map((threadId) => getThreadBrowseData(input.dbPath, threadId));
303
293
  const threads = browseEntries.map((entry) => entry.thread);
304
294
  const exportDir = await resolvePublicExportDir(input.publicExportDir);
305
- const exportBaseName = buildBatchExportBaseName(threads);
295
+ const exportBaseName = buildBatchExportBaseName(
296
+ threads.map((thread) => ({
297
+ cwd: thread.cwd,
298
+ updatedAtMs: thread.updated_at_ms ?? thread.updated_at * 1000,
299
+ })),
300
+ 'threads',
301
+ );
306
302
  const bundleDirectory = await createExportWorkspace(exportDir, exportBaseName);
307
303
  const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
308
304
  const usedBatchEntryBaseNames = new Set<string>();
@@ -2,10 +2,15 @@ import { stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import type { ParsedCodexTranscript } from './codex-browser-types';
4
4
  import { parseCodexTranscriptFile } from './codex-thread-parser';
5
+ import type { CodexTranscriptEventFilters } from './codex-transcript-filter';
6
+ import { shouldShowCodexTranscriptEvent } from './codex-transcript-filter';
7
+ import { runWithTranscriptLoadLimit } from './transcript-load-limiter';
5
8
  import { getFileFingerprint, hashCacheKeyParts, 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 = 'v2';
9
14
 
10
15
  const isMissingFileError = (error: unknown) => {
11
16
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
@@ -13,12 +18,18 @@ const isMissingFileError = (error: unknown) => {
13
18
 
14
19
  export const getCachedParsedCodexTranscript = async (sessionFile: string): Promise<ParsedCodexTranscript> => {
15
20
  const fingerprint = await getFileFingerprint(sessionFile);
16
- const key = `thread-${hashCacheKeyParts(path.basename(sessionFile), fingerprint)}`;
21
+ const key = `thread-${hashCacheKeyParts(CODEX_TRANSCRIPT_CACHE_VERSION, path.basename(sessionFile), fingerprint)}`;
17
22
 
18
- return withCachedJson(key, async () => parseCodexTranscriptFile(sessionFile));
23
+ return withCachedJson(key, async () =>
24
+ runWithTranscriptLoadLimit(() => parseCodexTranscriptFile(sessionFile), {
25
+ path: sessionFile,
26
+ source: 'codex-full',
27
+ }),
28
+ );
19
29
  };
20
30
 
21
31
  type CachedThreadTranscriptPreviewOptions = {
32
+ filters?: CodexTranscriptEventFilters;
22
33
  largeTranscriptThresholdBytes?: number;
23
34
  previewEventLimit?: number;
24
35
  };
@@ -53,22 +64,39 @@ export const getCachedThreadTranscriptPreview = async (
53
64
  ): Promise<ParsedCodexTranscript> => {
54
65
  const threshold = options.largeTranscriptThresholdBytes ?? LARGE_THREAD_SIZE_BYTES;
55
66
  const previewEventLimit = options.previewEventLimit ?? LARGE_THREAD_PREVIEW_EVENT_LIMIT;
67
+ const filters = options.filters;
56
68
  const fingerprint = await getFileFingerprint(sessionFile);
57
69
  const { fileSizeBytes, shouldDeferTranscriptLoad } = await getThreadRolloutLoadState(sessionFile, threshold);
58
- const key = `thread-preview-${hashCacheKeyParts(path.basename(sessionFile), fingerprint, String(threshold), String(previewEventLimit))}`;
70
+ const filterKey = filters ? JSON.stringify(filters) : 'all';
71
+ const key = `thread-preview-${hashCacheKeyParts(CODEX_TRANSCRIPT_CACHE_VERSION, path.basename(sessionFile), fingerprint, String(threshold), String(previewEventLimit), filterKey)}`;
59
72
 
60
73
  return withCachedJson(key, async () => {
61
74
  if (!shouldDeferTranscriptLoad) {
62
- return parseCodexTranscriptFile(sessionFile, {
63
- sourceFileSizeBytes: fileSizeBytes,
64
- });
75
+ return runWithTranscriptLoadLimit(
76
+ () =>
77
+ parseCodexTranscriptFile(sessionFile, {
78
+ sourceFileSizeBytes: fileSizeBytes,
79
+ }),
80
+ {
81
+ path: sessionFile,
82
+ source: 'codex-preview-full',
83
+ },
84
+ );
65
85
  }
66
86
 
67
- return parseCodexTranscriptFile(sessionFile, {
68
- includeRaw: false,
69
- maxEvents: previewEventLimit,
70
- maxTurnContexts: 0,
71
- sourceFileSizeBytes: fileSizeBytes,
72
- });
87
+ return runWithTranscriptLoadLimit(
88
+ () =>
89
+ parseCodexTranscriptFile(sessionFile, {
90
+ eventFilter: filters ? (event) => shouldShowCodexTranscriptEvent(event, filters) : undefined,
91
+ includeRaw: false,
92
+ maxTurnContexts: 0,
93
+ sourceFileSizeBytes: fileSizeBytes,
94
+ tailEventLimit: previewEventLimit,
95
+ }),
96
+ {
97
+ path: sessionFile,
98
+ source: 'codex-preview',
99
+ },
100
+ );
73
101
  });
74
102
  };
@@ -14,13 +14,27 @@ 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
+ tailEventLimit: number;
37
+ turnContexts: TurnContextRecord[];
24
38
  };
25
39
 
26
40
  const createEmptyStats = (): ThreadTranscriptStats => {
@@ -64,45 +78,74 @@ export const parseCodexTranscriptFile = async (
64
78
  const includeRaw = options.includeRaw ?? true;
65
79
  const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
66
80
  const maxTurnContexts = options.maxTurnContexts ?? Number.POSITIVE_INFINITY;
67
- let sequence = 0;
81
+ const tailEventLimit = options.tailEventLimit ?? Number.POSITIVE_INFINITY;
82
+ const eventFilter = options.eventFilter ?? (() => true);
83
+ const state: ParseCodexTranscriptState = {
84
+ eventFilter,
85
+ events,
86
+ includeRaw,
87
+ maxEvents,
88
+ maxTurnContexts,
89
+ sequence: 0,
90
+ shouldStop: false,
91
+ tailEventLimit,
92
+ turnContexts,
93
+ };
68
94
 
69
95
  for await (const parsed of readJsonlObjects(sessionFile)) {
70
96
  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;
78
- }
79
-
80
- const event = toThreadEvent(parsed, sequence, includeRaw);
81
- if (!event) {
82
- continue;
97
+ captureTranscriptRecord(parsed, state);
98
+ if (state.shouldStop) {
99
+ break;
83
100
  }
101
+ }
84
102
 
85
- events.push(event);
103
+ for (const event of events) {
86
104
  updateTranscriptStats(stats, event);
87
- sequence += 1;
88
-
89
- if (events.length >= maxEvents) {
90
- break;
91
- }
92
105
  }
93
106
 
94
107
  return {
95
108
  events,
96
- isPartial: Number.isFinite(maxEvents) || Number.isFinite(maxTurnContexts),
109
+ isPartial:
110
+ Number.isFinite(maxEvents) ||
111
+ Number.isFinite(maxTurnContexts) ||
112
+ Number.isFinite(tailEventLimit) ||
113
+ options.eventFilter !== undefined,
97
114
  rawIncluded: includeRaw,
98
115
  sessionMeta,
99
116
  sourceFileSizeBytes: options.sourceFileSizeBytes ?? null,
100
117
  stats,
101
- statsArePartial: Number.isFinite(maxEvents),
118
+ statsArePartial:
119
+ Number.isFinite(maxEvents) || Number.isFinite(tailEventLimit) || options.eventFilter !== undefined,
102
120
  turnContexts,
103
121
  };
104
122
  };
105
123
 
124
+ const captureTranscriptRecord = (parsed: Record<string, JsonValue>, state: ParseCodexTranscriptState) => {
125
+ if (asString(parsed.type) === 'turn_context') {
126
+ if (state.turnContexts.length < state.maxTurnContexts) {
127
+ captureTurnContext(parsed, state.turnContexts);
128
+ }
129
+ return;
130
+ }
131
+
132
+ const event = toThreadEvent(parsed, state.sequence, state.includeRaw);
133
+ if (!event) {
134
+ return;
135
+ }
136
+
137
+ state.sequence += 1;
138
+ if (!state.eventFilter(event)) {
139
+ return;
140
+ }
141
+
142
+ state.events.push(event);
143
+ if (state.events.length > state.tailEventLimit) {
144
+ state.events.shift();
145
+ }
146
+ state.shouldStop = state.events.length >= state.maxEvents;
147
+ };
148
+
106
149
  const captureSessionMeta = (parsed: Record<string, JsonValue>, sessionMeta: SessionMetaExtended) => {
107
150
  if (parsed.type !== 'session_meta') {
108
151
  return;
@@ -261,12 +304,13 @@ const createMessageEvent = (
261
304
  ): MessageEvent | null => {
262
305
  const role = asString(payload.role);
263
306
  const content = payload.content;
307
+ const text = extractText(content);
264
308
  if (!role || content === undefined) {
265
309
  return null;
266
310
  }
267
311
 
268
312
  return {
269
- isHiddenByDefault: shouldHideTranscriptText(role, extractText(content)),
313
+ isHiddenByDefault: shouldHideTranscriptText(role, text),
270
314
  kind: 'message',
271
315
  memoryCitation: null,
272
316
  model: asString(payload.model),
@@ -274,7 +318,7 @@ const createMessageEvent = (
274
318
  raw,
275
319
  role,
276
320
  sequence,
277
- text: extractText(content),
321
+ text,
278
322
  timestamp,
279
323
  variant: 'message',
280
324
  };
@@ -286,8 +330,9 @@ const createUserMessageEvent = (
286
330
  sequence: number,
287
331
  timestamp: string | null,
288
332
  ): MessageEvent => {
333
+ const text = stripMemoryCitationBlocks(asString(payload.message) ?? '');
289
334
  return {
290
- isHiddenByDefault: shouldHideTranscriptText('user', asString(payload.message)?.trim() ?? ''),
335
+ isHiddenByDefault: shouldHideTranscriptText('user', text),
291
336
  kind: 'message',
292
337
  memoryCitation: null,
293
338
  model: null,
@@ -295,7 +340,7 @@ const createUserMessageEvent = (
295
340
  raw,
296
341
  role: 'user',
297
342
  sequence,
298
- text: asString(payload.message)?.trim() ?? '',
343
+ text,
299
344
  timestamp,
300
345
  variant: 'user_message',
301
346
  };
@@ -307,8 +352,9 @@ const createAgentMessageEvent = (
307
352
  sequence: number,
308
353
  timestamp: string | null,
309
354
  ): MessageEvent => {
355
+ const text = stripMemoryCitationBlocks(asString(payload.message) ?? '');
310
356
  return {
311
- isHiddenByDefault: false,
357
+ isHiddenByDefault: shouldHideTranscriptText('assistant', text),
312
358
  kind: 'message',
313
359
  memoryCitation: payload.memory_citation ?? null,
314
360
  model: asString(payload.model),
@@ -316,7 +362,7 @@ const createAgentMessageEvent = (
316
362
  raw,
317
363
  role: 'assistant',
318
364
  sequence,
319
- text: asString(payload.message)?.trim() ?? '',
365
+ text,
320
366
  timestamp,
321
367
  variant: 'agent_message',
322
368
  };
@@ -548,24 +594,29 @@ const parseExecCommandArguments = (argumentsText: string | null) => {
548
594
 
549
595
  const extractText = (content: JsonValue): string => {
550
596
  if (typeof content === 'string') {
551
- return content.trim();
597
+ return stripMemoryCitationBlocks(content);
552
598
  }
553
599
 
554
600
  if (Array.isArray(content)) {
555
- return content
556
- .map((entry) => extractTextPart(entry))
557
- .filter(Boolean)
558
- .join('\n\n')
559
- .trim();
601
+ return stripMemoryCitationBlocks(
602
+ content
603
+ .map((entry) => extractTextPart(entry))
604
+ .filter(Boolean)
605
+ .join('\n\n'),
606
+ );
560
607
  }
561
608
 
562
609
  if (content && typeof content === 'object') {
563
- return asString((content as Record<string, JsonValue>).text)?.trim() ?? '';
610
+ return stripMemoryCitationBlocks(asString((content as Record<string, JsonValue>).text) ?? '');
564
611
  }
565
612
 
566
613
  return '';
567
614
  };
568
615
 
616
+ const stripMemoryCitationBlocks = (text: string): string => {
617
+ return stripCodexAppDirectiveLines(text.replace(/<oai-mem-citation>[\s\S]*?<\/oai-mem-citation>/gu, ''));
618
+ };
619
+
569
620
  const extractTextPart = (entry: JsonValue): string => {
570
621
  const objectValue = asObject(entry);
571
622
  if (!objectValue) {
@@ -0,0 +1,31 @@
1
+ import type { ThreadEvent } from './codex-browser-types';
2
+
3
+ const isCommentaryMessage = (event: ThreadEvent) =>
4
+ event.kind === 'message' && event.role === 'assistant' && event.phase === 'commentary';
5
+
6
+ export type CodexTranscriptEventFilters = {
7
+ showCommentary: boolean;
8
+ showExtraEvents: boolean;
9
+ showToolCalls: boolean;
10
+ showUserMessages: boolean;
11
+ };
12
+
13
+ export const shouldShowCodexTranscriptEvent = (event: ThreadEvent, filters: CodexTranscriptEventFilters) => {
14
+ if (isCommentaryMessage(event) && !filters.showCommentary) {
15
+ return false;
16
+ }
17
+
18
+ if (event.kind === 'message') {
19
+ if (event.role === 'user' && !filters.showUserMessages) {
20
+ return false;
21
+ }
22
+
23
+ return !event.isHiddenByDefault || filters.showExtraEvents;
24
+ }
25
+
26
+ if (event.kind === 'tool_call' || event.kind === 'tool_output') {
27
+ return filters.showToolCalls;
28
+ }
29
+
30
+ return filters.showExtraEvents;
31
+ };
@@ -26,7 +26,9 @@ import {
26
26
  renderDocumentTitle,
27
27
  renderMetadataBlock,
28
28
  renderSection,
29
+ stripCodexAppDirectiveLines,
29
30
  } from './shared';
31
+ import { runWithTranscriptLoadLimit } from './transcript-load-limiter';
30
32
 
31
33
  export const renderCodexSessionFile = async (
32
34
  target: CodexTranscriptExportTarget,
@@ -35,7 +37,14 @@ export const renderCodexSessionFile = async (
35
37
  let transcriptState: CodexTranscriptState;
36
38
 
37
39
  try {
38
- transcriptState = await collectCodexTranscript(target.sessionFile, options, target.thread?.model ?? null);
40
+ transcriptState = await runWithTranscriptLoadLimit(
41
+ () => collectCodexTranscript(target.sessionFile, options, target.thread?.model ?? null),
42
+ {
43
+ id: target.thread?.id,
44
+ path: target.sessionFile,
45
+ source: 'codex-export-inline',
46
+ },
47
+ );
39
48
  } catch (error) {
40
49
  const message = error instanceof Error ? error.message : String(error);
41
50
  throw new Error(`Failed to read Codex transcript ${target.sessionFile}: ${message}`);
@@ -78,19 +87,28 @@ export const writeCodexSessionFileExport = async (
78
87
  let wroteSection = false;
79
88
 
80
89
  try {
81
- transcriptStream = await createExportWriteStream(transcriptOutputPath);
82
- for await (const parsed of readJsonlObjects(target.sessionFile)) {
83
- captureSessionMeta(parsed, state.sessionMeta);
84
- const block = renderCodexTranscriptRecord(parsed, options, state);
85
- if (!block) {
86
- continue;
87
- }
88
-
89
- transcriptStream.write(transform(wroteSection ? `${getSectionSeparator()}${block}` : block));
90
- wroteSection = true;
91
- }
92
- await finalizeExportWriteStream(transcriptStream);
93
- transcriptStream = null;
90
+ await runWithTranscriptLoadLimit(
91
+ async () => {
92
+ transcriptStream = await createExportWriteStream(transcriptOutputPath);
93
+ for await (const parsed of readJsonlObjects(target.sessionFile)) {
94
+ captureSessionMeta(parsed, state.sessionMeta);
95
+ const block = renderCodexTranscriptRecord(parsed, options, state);
96
+ if (!block) {
97
+ continue;
98
+ }
99
+
100
+ transcriptStream.write(transform(wroteSection ? `${getSectionSeparator()}${block}` : block));
101
+ wroteSection = true;
102
+ }
103
+ await finalizeExportWriteStream(transcriptStream);
104
+ transcriptStream = null;
105
+ },
106
+ {
107
+ id: target.thread?.id,
108
+ path: target.sessionFile,
109
+ source: 'codex-export-stream',
110
+ },
111
+ );
94
112
 
95
113
  if (!wroteSection) {
96
114
  return false;
@@ -189,7 +207,7 @@ const processCodexMessageRecord = (
189
207
  options: CodexTranscriptRenderOptions,
190
208
  state: CodexTranscriptState,
191
209
  ) => {
192
- return renderMessageBlock(message, options.outputFormat, state.assistantModel, options.includeCommentary);
210
+ return renderMessageBlock(message, options.outputFormat, state, options.includeCommentary);
193
211
  };
194
212
 
195
213
  const buildStreamExportPrefix = (
@@ -542,7 +560,7 @@ const extractToolRecord = (parsed: Record<string, JsonValue>): ToolRecord | null
542
560
  const renderMessageBlock = (
543
561
  message: MessageRecord,
544
562
  outputFormat: ExportFormat,
545
- assistantModel: string | null,
563
+ state: CodexTranscriptState,
546
564
  includeCommentary: boolean,
547
565
  ): string => {
548
566
  if (message.role !== 'user' && message.role !== 'assistant') {
@@ -553,12 +571,13 @@ const renderMessageBlock = (
553
571
  return '';
554
572
  }
555
573
 
556
- const text = cleanExtractedText(stripPreviewBlock(extractText(message.content))).trim();
574
+ const extractedText = extractText(message.content);
575
+ const text = stripCodexAppDirectiveLines(cleanExtractedText(stripPreviewBlock(extractedText)));
557
576
  if (!text || shouldSkipMessage(message.role, text)) {
558
577
  return '';
559
578
  }
560
579
 
561
- const title = message.role === 'user' ? 'User' : formatModelLabel(message.model ?? assistantModel);
580
+ const title = message.role === 'user' ? 'User' : formatModelLabel(message.model ?? state.assistantModel);
562
581
  const body = message.phase ? `Phase: ${message.phase}\n\n${text}` : text;
563
582
 
564
583
  return renderSection(title, body, outputFormat);
@@ -570,7 +589,8 @@ const renderToolBlock = (tool: ToolRecord, outputFormat: ExportFormat): string =
570
589
  return details ? renderSection('Tool', details, outputFormat) : '';
571
590
  }
572
591
 
573
- const summary = formatToolOutputSummary(tool.outputText ?? '', outputFormat);
592
+ const outputText = tool.outputText ?? '';
593
+ const summary = formatToolOutputSummary(outputText, outputFormat);
574
594
  return summary ? renderSection('Tool Output', summary, outputFormat) : '';
575
595
  };
576
596