spiracha 1.0.0 → 1.1.1

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 (92) hide show
  1. package/AGENTS.md +31 -1
  2. package/README.md +61 -7
  3. package/apps/ui/AGENTS.md +70 -0
  4. package/apps/ui/README.md +72 -0
  5. package/apps/ui/dist/client/assets/_threadId-CAIeH5mq.js +1 -0
  6. package/apps/ui/dist/client/assets/analytics-CqWZmyV6.js +1 -0
  7. package/apps/ui/dist/client/assets/checkbox-DXM4lkJq.js +1 -0
  8. package/apps/ui/dist/client/assets/data-table-DnPYMPCD.js +4 -0
  9. package/apps/ui/dist/client/assets/delete-confirm-dialog-CcZaRX33.js +11 -0
  10. package/apps/ui/dist/client/assets/download-DOwxk-cG.js +1 -0
  11. package/apps/ui/dist/client/assets/es2015-Bm0kEzx2.js +41 -0
  12. package/apps/ui/dist/client/assets/formatters-C12LmYaa.js +1 -0
  13. package/apps/ui/dist/client/assets/index-DdJ7ahIt.js +22 -0
  14. package/apps/ui/dist/client/assets/input-CEsI7EpI.js +1 -0
  15. package/apps/ui/dist/client/assets/metric-card-9jwBF7rG.js +1 -0
  16. package/apps/ui/dist/client/assets/page-header-Dr_h1CVv.js +1 -0
  17. package/apps/ui/dist/client/assets/projects._project-uyNGnpjH.js +1 -0
  18. package/apps/ui/dist/client/assets/projects._project-zoM8d2nH.js +1 -0
  19. package/apps/ui/dist/client/assets/projects.index-D1CWVN-O.js +1 -0
  20. package/apps/ui/dist/client/assets/projects.index-DukMuny6.js +1 -0
  21. package/apps/ui/dist/client/assets/routes-Gr2Wwh83.js +1 -0
  22. package/apps/ui/dist/client/assets/select-CFim44gT.js +1 -0
  23. package/apps/ui/dist/client/assets/settings-DqhyDxo2.js +1 -0
  24. package/apps/ui/dist/client/assets/styles-CMrP9Jb4.css +1 -0
  25. package/apps/ui/dist/client/assets/threads._threadId-DT75NiBa.js +1 -0
  26. package/apps/ui/dist/client/assets/threads._threadId-Df5VXIuZ.js +7 -0
  27. package/apps/ui/dist/client/favicon.ico +0 -0
  28. package/apps/ui/dist/client/logo192.png +0 -0
  29. package/apps/ui/dist/client/logo512.png +0 -0
  30. package/apps/ui/dist/client/manifest.json +25 -0
  31. package/apps/ui/dist/client/robots.txt +3 -0
  32. package/apps/ui/dist/server/assets/__23tanstack-start-plugin-adapters-BzCA6dXo.js +5 -0
  33. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-C0V305Nt.js +99 -0
  34. package/apps/ui/dist/server/assets/_threadId-B6SrBR9E.js +6 -0
  35. package/apps/ui/dist/server/assets/analytics-BMxW_bZL.js +139 -0
  36. package/apps/ui/dist/server/assets/button-CmTDnzOn.js +46 -0
  37. package/apps/ui/dist/server/assets/checkbox-C0hovF41.js +19 -0
  38. package/apps/ui/dist/server/assets/codex-queries-CAF6HYiG.js +109 -0
  39. package/apps/ui/dist/server/assets/codex-server-BFZq2Y2O.js +2062 -0
  40. package/apps/ui/dist/server/assets/data-table-Cdct823O.js +189 -0
  41. package/apps/ui/dist/server/assets/delete-confirm-dialog-CWqcTXTF.js +139 -0
  42. package/apps/ui/dist/server/assets/download-C5rkk_Bo.js +289 -0
  43. package/apps/ui/dist/server/assets/formatters-FJaGZgJk.js +91 -0
  44. package/apps/ui/dist/server/assets/input-B4tEzctc.js +46 -0
  45. package/apps/ui/dist/server/assets/loading-panel-DbLdvjtR.js +27 -0
  46. package/apps/ui/dist/server/assets/metric-card-ByEeLu0r.js +23 -0
  47. package/apps/ui/dist/server/assets/model-label-B1NWGc65.js +13 -0
  48. package/apps/ui/dist/server/assets/page-header-CxdZM86z.js +25 -0
  49. package/apps/ui/dist/server/assets/path-transforms-DL2IwtYd.js +31 -0
  50. package/apps/ui/dist/server/assets/projects._project-CJ7l0ynC.js +18 -0
  51. package/apps/ui/dist/server/assets/projects._project-CLSohrBp.js +26 -0
  52. package/apps/ui/dist/server/assets/projects._project-CcJLp_A8.js +337 -0
  53. package/apps/ui/dist/server/assets/projects.index-CaplpeMy.js +26 -0
  54. package/apps/ui/dist/server/assets/projects.index-srtogpuF.js +172 -0
  55. package/apps/ui/dist/server/assets/router-C_w-haH6.js +307 -0
  56. package/apps/ui/dist/server/assets/routes-BhbxvJE7.js +34 -0
  57. package/apps/ui/dist/server/assets/routes-CPe-ppmC.js +169 -0
  58. package/apps/ui/dist/server/assets/select-GW76p-ld.js +76 -0
  59. package/apps/ui/dist/server/assets/settings-MvWDgc1u.js +100 -0
  60. package/apps/ui/dist/server/assets/settings-store-DpEJEQ7M.js +52 -0
  61. package/apps/ui/dist/server/assets/sqlite-error-LZDrnxdd.js +13 -0
  62. package/apps/ui/dist/server/assets/start-HeKLHD9b.js +4 -0
  63. package/apps/ui/dist/server/assets/threads._threadId-BSSK4nkI.js +26 -0
  64. package/apps/ui/dist/server/assets/threads._threadId-Ba7vv6-K.js +18 -0
  65. package/apps/ui/dist/server/assets/threads._threadId-euyNckhj.js +1059 -0
  66. package/apps/ui/dist/server/assets/utils-C_uf36nf.js +8 -0
  67. package/apps/ui/dist/server/server.js +5678 -0
  68. package/package.json +53 -7
  69. package/src/export-chats.ts +4 -18
  70. package/src/lib/claude-exporter.ts +1 -1
  71. package/src/lib/codex-analytics.ts +100 -0
  72. package/src/lib/codex-browser-db.ts +605 -0
  73. package/src/lib/codex-browser-export.ts +429 -0
  74. package/src/lib/codex-browser-types.ts +224 -0
  75. package/src/lib/codex-exporter-cli.ts +6 -1
  76. package/src/lib/codex-exporter-db.ts +19 -20
  77. package/src/lib/codex-exporter-transcript.ts +158 -34
  78. package/src/lib/codex-exporter-types.ts +8 -0
  79. package/src/lib/codex-thread-cache.ts +58 -0
  80. package/src/lib/codex-thread-parser.ts +604 -0
  81. package/src/lib/interactive-cli.ts +10 -25
  82. package/src/lib/model-label.ts +24 -0
  83. package/src/lib/native-open.ts +54 -0
  84. package/src/lib/path-transforms.ts +46 -0
  85. package/src/lib/shared.ts +15 -1
  86. package/src/lib/sqlite-error.ts +14 -0
  87. package/src/lib/sqlite-retry.ts +53 -0
  88. package/src/lib/ui-cache.ts +96 -0
  89. package/src/lib/ui-export-files.ts +77 -0
  90. package/src/mcp-server.ts +1 -0
  91. package/src/spiracha.ts +16 -4
  92. package/src/ui-cli.ts +310 -0
@@ -1,6 +1,6 @@
1
- import { Database } from 'bun:sqlite';
2
1
  import { readdir } from 'node:fs/promises';
3
2
  import path from 'node:path';
3
+ import { withReadonlyDb } from './codex-browser-db';
4
4
  import {
5
5
  type CodexCliOptions,
6
6
  DEFAULT_CODEX_DIR,
@@ -17,33 +17,29 @@ export const loadThreadData = (dbPath: string, options: CodexCliOptions): Thread
17
17
  const parentByChildId = new Map<string, SpawnEdgeRow>();
18
18
  const childEdgesByParentId = new Map<string, SpawnEdgeRow[]>();
19
19
 
20
- let db: Database | null = null;
21
-
22
20
  try {
23
- db = new Database(dbPath, { readonly: true });
24
-
25
- const threadQuery = buildThreadQuery(options);
26
- const threadRows = db.query(threadQuery.sql).all(...threadQuery.params) as ThreadRow[];
21
+ withReadonlyDb(dbPath, (db) => {
22
+ const threadQuery = buildThreadQuery(options);
23
+ const threadRows = db.query(threadQuery.sql).all(...threadQuery.params) as ThreadRow[];
27
24
 
28
- for (const row of threadRows) {
29
- threadsById.set(row.id, row);
30
- }
25
+ for (const row of threadRows) {
26
+ threadsById.set(row.id, row);
27
+ }
31
28
 
32
- const edgeQuery = buildSpawnEdgeQuery([...threadsById.keys()], options);
33
- const edgeRows = db.query(edgeQuery.sql).all(...edgeQuery.params) as SpawnEdgeRow[];
29
+ const edgeQuery = buildSpawnEdgeQuery([...threadsById.keys()], options);
30
+ const edgeRows = db.query(edgeQuery.sql).all(...edgeQuery.params) as SpawnEdgeRow[];
34
31
 
35
- for (const row of edgeRows) {
36
- parentByChildId.set(row.child_thread_id, row);
32
+ for (const row of edgeRows) {
33
+ parentByChildId.set(row.child_thread_id, row);
37
34
 
38
- const existing = childEdgesByParentId.get(row.parent_thread_id) ?? [];
39
- existing.push(row);
40
- childEdgesByParentId.set(row.parent_thread_id, existing);
41
- }
35
+ const existing = childEdgesByParentId.get(row.parent_thread_id) ?? [];
36
+ existing.push(row);
37
+ childEdgesByParentId.set(row.parent_thread_id, existing);
38
+ }
39
+ });
42
40
  } catch (error) {
43
41
  const message = error instanceof Error ? error.message : String(error);
44
42
  throw new Error(`Failed to read thread database at ${dbPath}: ${message}`);
45
- } finally {
46
- db?.close();
47
43
  }
48
44
 
49
45
  return {
@@ -210,14 +206,17 @@ export const toOutputRelativePath = (
210
206
  return flatName;
211
207
  }
212
208
 
209
+ // Prefer preserving the input sessions tree when the rollout lives under the configured input root.
213
210
  if (normalized.startsWith(`${inputRoot}${path.sep}`)) {
214
211
  return path.relative(inputRoot, normalized).replace(/\.jsonl$/i, extension);
215
212
  }
216
213
 
214
+ // Fall back to a stable Codex-relative path when the file is under ~/.codex.
217
215
  if (normalized.startsWith(`${codexRoot}${path.sep}`)) {
218
216
  return path.relative(codexRoot, normalized).replace(/\.jsonl$/i, extension);
219
217
  }
220
218
 
219
+ // Otherwise collapse to the basename so ad hoc session files cannot escape the output directory.
221
220
  return path.basename(normalized).replace(/\.jsonl$/i, extension);
222
221
  };
223
222
 
@@ -1,4 +1,7 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { rm } from 'node:fs/promises';
1
3
  import path from 'node:path';
4
+ import { finished } from 'node:stream/promises';
2
5
  import { matchesFilters, toCodexRelativePath } from './codex-exporter-db';
3
6
  import type { CodexCliOptions, ExportTarget, MessageRecord, SessionMeta, ToolRecord } from './codex-exporter-types';
4
7
  import {
@@ -6,8 +9,11 @@ import {
6
9
  asString,
7
10
  cleanExtractedText,
8
11
  cleanInlineTitle,
12
+ createExportWriteStream,
9
13
  type ExportFormat,
14
+ finalizeExportWriteStream,
10
15
  formatInlineLiteral,
16
+ formatModelLabel,
11
17
  type JsonValue,
12
18
  type MetadataEntry,
13
19
  readJsonlObjects,
@@ -20,7 +26,7 @@ export const convertSessionFile = async (target: ExportTarget, options: CodexCli
20
26
  let transcriptState: CodexTranscriptState;
21
27
 
22
28
  try {
23
- transcriptState = await collectCodexTranscript(target.sessionFile, options);
29
+ transcriptState = await collectCodexTranscript(target.sessionFile, options, target.thread?.model ?? null);
24
30
  } catch (error) {
25
31
  const message = error instanceof Error ? error.message : String(error);
26
32
  throw new Error(`Failed to read Codex transcript ${target.sessionFile}: ${message}`);
@@ -49,14 +55,85 @@ export const convertSessionFile = async (target: ExportTarget, options: CodexCli
49
55
  return parts.join('\n').trimEnd() + '\n';
50
56
  };
51
57
 
58
+ type TranscriptTextTransform = (text: string) => string;
59
+
60
+ export const writeSessionFileExport = async (
61
+ target: ExportTarget,
62
+ options: CodexCliOptions,
63
+ outputPath: string,
64
+ transform: TranscriptTextTransform = (text) => text,
65
+ ): Promise<boolean> => {
66
+ const transcriptOutputPath = `${outputPath}.transcript.tmp`;
67
+ let transcriptStream: any = null;
68
+ const state: CodexTranscriptState = {
69
+ assistantModel: target.thread?.model ?? null,
70
+ sections: [],
71
+ sessionMeta: {},
72
+ startedTranscript: false,
73
+ };
74
+ let wroteSection = false;
75
+
76
+ try {
77
+ transcriptStream = await createExportWriteStream(transcriptOutputPath);
78
+ for await (const parsed of readJsonlObjects(target.sessionFile)) {
79
+ captureSessionMeta(parsed, state.sessionMeta);
80
+ const block = renderCodexTranscriptRecord(parsed, options, state);
81
+ if (!block) {
82
+ continue;
83
+ }
84
+
85
+ transcriptStream.write(transform(wroteSection ? `${getSectionSeparator(options)}${block}` : block));
86
+ wroteSection = true;
87
+ }
88
+ await finalizeExportWriteStream(transcriptStream);
89
+ transcriptStream = null;
90
+
91
+ if (!matchesFilters(target.thread?.cwd ?? state.sessionMeta.cwd ?? null, options) || !wroteSection) {
92
+ return false;
93
+ }
94
+
95
+ const outputStream = await createExportWriteStream(outputPath);
96
+ try {
97
+ const prefix = buildStreamExportPrefix(target, state.sessionMeta, options);
98
+ if (prefix) {
99
+ outputStream.write(transform(prefix));
100
+ }
101
+
102
+ const transcriptReadStream = createReadStream(transcriptOutputPath, { encoding: 'utf8' });
103
+ transcriptReadStream.pipe(outputStream, { end: false });
104
+ await finished(transcriptReadStream);
105
+ outputStream.write('\n');
106
+ await finalizeExportWriteStream(outputStream);
107
+ } catch (error) {
108
+ outputStream.destroy();
109
+ throw error;
110
+ }
111
+
112
+ return true;
113
+ } catch (error) {
114
+ if (transcriptStream) {
115
+ transcriptStream.destroy();
116
+ }
117
+ throw error;
118
+ } finally {
119
+ await rm(transcriptOutputPath, { force: true });
120
+ }
121
+ };
122
+
52
123
  type CodexTranscriptState = {
124
+ assistantModel: string | null;
53
125
  sessionMeta: SessionMeta;
54
126
  sections: string[];
55
127
  startedTranscript: boolean;
56
128
  };
57
129
 
58
- const collectCodexTranscript = async (sessionFile: string, options: CodexCliOptions): Promise<CodexTranscriptState> => {
130
+ const collectCodexTranscript = async (
131
+ sessionFile: string,
132
+ options: CodexCliOptions,
133
+ assistantModel: string | null = null,
134
+ ): Promise<CodexTranscriptState> => {
59
135
  const state: CodexTranscriptState = {
136
+ assistantModel,
60
137
  sections: [],
61
138
  sessionMeta: {},
62
139
  startedTranscript: false,
@@ -69,46 +146,52 @@ const collectCodexTranscript = async (sessionFile: string, options: CodexCliOpti
69
146
  return state;
70
147
  };
71
148
 
149
+ const getSectionSeparator = (options: CodexCliOptions) => {
150
+ return options.optimized ? '\n\n' : '\n';
151
+ };
152
+
72
153
  const processCodexTranscriptRecord = (
73
154
  parsed: Record<string, JsonValue>,
74
155
  options: CodexCliOptions,
75
156
  state: CodexTranscriptState,
76
157
  ) => {
77
158
  captureSessionMeta(parsed, state.sessionMeta);
159
+ const block = renderCodexTranscriptRecord(parsed, options, state);
160
+ if (block) {
161
+ state.sections.push(block);
162
+ }
163
+ };
78
164
 
165
+ const renderCodexTranscriptRecord = (
166
+ parsed: Record<string, JsonValue>,
167
+ options: CodexCliOptions,
168
+ state: CodexTranscriptState,
169
+ ) => {
79
170
  const message = extractMessageRecord(parsed);
80
171
  if (message) {
81
- processCodexMessageRecord(message, options, state);
82
- return;
172
+ return processCodexMessageRecord(message, options, state);
83
173
  }
84
174
 
85
175
  if (!options.includeTools) {
86
- return;
176
+ return '';
87
177
  }
88
178
 
89
179
  const tool = extractToolRecord(parsed);
90
180
  if (!tool) {
91
- return;
181
+ return '';
92
182
  }
93
183
 
94
- const block = options.optimized
184
+ return options.optimized
95
185
  ? renderCompactToolBlock(tool, options.outputFormat)
96
186
  : renderToolBlock(tool, options.outputFormat);
97
- if (block) {
98
- state.sections.push(block);
99
- }
100
187
  };
101
188
 
102
189
  const processCodexMessageRecord = (message: MessageRecord, options: CodexCliOptions, state: CodexTranscriptState) => {
103
190
  if (options.optimized) {
104
- processOptimizedCodexMessageRecord(message, options, state);
105
- return;
191
+ return processOptimizedCodexMessageRecord(message, options, state);
106
192
  }
107
193
 
108
- const block = renderMessageBlock(message, options.outputFormat);
109
- if (block) {
110
- state.sections.push(block);
111
- }
194
+ return renderMessageBlock(message, options.outputFormat, state.assistantModel, options.includeCommentary);
112
195
  };
113
196
 
114
197
  const processOptimizedCodexMessageRecord = (
@@ -117,25 +200,44 @@ const processOptimizedCodexMessageRecord = (
117
200
  state: CodexTranscriptState,
118
201
  ) => {
119
202
  if (message.role !== 'user' && message.role !== 'assistant') {
120
- return;
203
+ return '';
204
+ }
205
+
206
+ if (message.role === 'assistant' && message.phase === 'commentary' && !options.includeCommentary) {
207
+ return '';
121
208
  }
122
209
 
123
210
  const compact = compactMessageText(message, true);
124
211
  if (!compact) {
125
- return;
212
+ return '';
126
213
  }
127
214
 
128
215
  if (!state.startedTranscript) {
129
216
  if (shouldSkipOptimizedPrelude(message.role, compact)) {
130
- return;
217
+ return '';
131
218
  }
132
219
  state.startedTranscript = true;
133
220
  }
134
221
 
135
- const rendered = renderCompactBlock(message, compact, options.outputFormat);
136
- if (rendered) {
137
- state.sections.push(rendered);
222
+ return renderCompactBlock(message, compact, options.outputFormat, state.assistantModel);
223
+ };
224
+
225
+ const buildStreamExportPrefix = (target: ExportTarget, sessionMeta: SessionMeta, options: CodexCliOptions) => {
226
+ if (options.optimized) {
227
+ return '';
138
228
  }
229
+
230
+ const title = getTitle(target, sessionMeta);
231
+ const metadata = buildMetadataEntries(target, sessionMeta, options);
232
+ const parts = [
233
+ renderDocumentTitle(title, options.outputFormat),
234
+ '',
235
+ renderMetadataBlock(metadata, options.outputFormat),
236
+ ]
237
+ .filter(Boolean)
238
+ .join('\n');
239
+
240
+ return `${parts}\n`;
139
241
  };
140
242
 
141
243
  export const compactMessageText = (message: MessageRecord, optimized: boolean): string => {
@@ -183,17 +285,18 @@ export const formatToolOutputSummary = (outputText: string, outputFormat: Export
183
285
 
184
286
  export const parseExecCommandArguments = (argumentsText?: string) => {
185
287
  if (!argumentsText) {
186
- return { cmd: null as string | null, workdir: null as string | null };
288
+ return { argumentsParseFailed: false, cmd: null as string | null, workdir: null as string | null };
187
289
  }
188
290
 
189
291
  try {
190
292
  const parsed = JSON.parse(argumentsText) as Record<string, unknown>;
191
293
  return {
294
+ argumentsParseFailed: false,
192
295
  cmd: typeof parsed.cmd === 'string' ? parsed.cmd : null,
193
296
  workdir: typeof parsed.workdir === 'string' ? parsed.workdir : null,
194
297
  };
195
298
  } catch {
196
- return { cmd: null as string | null, workdir: null as string | null };
299
+ return { argumentsParseFailed: true, cmd: null as string | null, workdir: null as string | null };
197
300
  }
198
301
  };
199
302
 
@@ -397,7 +500,11 @@ const extractMessageRecord = (parsed: Record<string, JsonValue>): MessageRecord
397
500
  }
398
501
 
399
502
  const payload = asObject(parsed.payload);
400
- if (!payload || payload.type !== 'message') {
503
+ if (!payload) {
504
+ return null;
505
+ }
506
+
507
+ if (payload.type !== 'message' && payload.type !== 'agent_message' && payload.type !== 'user_message') {
401
508
  return null;
402
509
  }
403
510
 
@@ -405,15 +512,17 @@ const extractMessageRecord = (parsed: Record<string, JsonValue>): MessageRecord
405
512
  };
406
513
 
407
514
  const normalizeMessage = (value: Record<string, JsonValue>): MessageRecord | null => {
408
- const role = asString(value.role);
409
- const content = value.content;
515
+ const type = asString(value.type);
516
+ const role =
517
+ asString(value.role) ?? (type === 'agent_message' ? 'assistant' : type === 'user_message' ? 'user' : null);
518
+ const content = value.content ?? asString(value.message);
410
519
  const phase = asString(value.phase);
411
520
 
412
521
  if (!role || content === undefined) {
413
522
  return null;
414
523
  }
415
524
 
416
- return { content, phase: phase ?? undefined, role };
525
+ return { content, model: asString(value.model), phase: phase ?? undefined, role };
417
526
  };
418
527
 
419
528
  const extractToolRecord = (parsed: Record<string, JsonValue>): ToolRecord | null => {
@@ -462,17 +571,26 @@ const extractToolRecord = (parsed: Record<string, JsonValue>): ToolRecord | null
462
571
  return null;
463
572
  };
464
573
 
465
- const renderMessageBlock = (message: MessageRecord, outputFormat: ExportFormat): string => {
574
+ const renderMessageBlock = (
575
+ message: MessageRecord,
576
+ outputFormat: ExportFormat,
577
+ assistantModel: string | null,
578
+ includeCommentary: boolean,
579
+ ): string => {
466
580
  if (message.role !== 'user' && message.role !== 'assistant') {
467
581
  return '';
468
582
  }
469
583
 
584
+ if (message.role === 'assistant' && message.phase === 'commentary' && !includeCommentary) {
585
+ return '';
586
+ }
587
+
470
588
  const text = cleanExtractedText(extractText(message.content)).trim();
471
589
  if (!text || shouldSkipMessage(message.role, text)) {
472
590
  return '';
473
591
  }
474
592
 
475
- const title = message.role === 'user' ? 'User' : 'Assistant';
593
+ const title = message.role === 'user' ? 'User' : formatModelLabel(message.model ?? assistantModel);
476
594
  const body = message.phase ? `Phase: ${message.phase}\n\n${text}` : text;
477
595
 
478
596
  return renderSection(title, body, outputFormat);
@@ -488,8 +606,13 @@ const renderToolBlock = (tool: ToolRecord, outputFormat: ExportFormat): string =
488
606
  return summary ? renderSection('Tool Output', summary, outputFormat) : '';
489
607
  };
490
608
 
491
- const renderCompactBlock = (message: MessageRecord, text: string, outputFormat: ExportFormat): string => {
492
- const prefix = message.role === 'user' ? 'U:' : 'A:';
609
+ const renderCompactBlock = (
610
+ message: MessageRecord,
611
+ text: string,
612
+ outputFormat: ExportFormat,
613
+ assistantModel: string | null,
614
+ ): string => {
615
+ const prefix = message.role === 'user' ? 'U:' : `${formatModelLabel(message.model ?? assistantModel)}:`;
493
616
  const lines = text.split('\n');
494
617
  const [firstLine, ...rest] = lines;
495
618
 
@@ -525,11 +648,12 @@ const stripPreviewBlock = (text: string): string => {
525
648
 
526
649
  const first = parts[0];
527
650
  const second = parts[1];
651
+ const isTranscriptHeading = (value: string) => /^##\s+.+$/i.test(value);
528
652
  const looksLikePreview =
529
653
  !/^([UA]):/i.test(first) &&
530
- !/^##\s+(User|Assistant)\s*$/i.test(first) &&
654
+ !isTranscriptHeading(first) &&
531
655
  /^([UA]):/i.test(second) === false &&
532
- /^##\s+(User|Assistant)\s*$/i.test(second);
656
+ isTranscriptHeading(second);
533
657
 
534
658
  if (!looksLikePreview) {
535
659
  return text.trim();
@@ -10,6 +10,7 @@ export type CodexCliOptions = {
10
10
  projectFilter: string | null;
11
11
  threadIds: string[];
12
12
  optimized: boolean;
13
+ includeCommentary: boolean;
13
14
  includeTools: boolean;
14
15
  outputFormat: ExportFormat;
15
16
  flat: boolean;
@@ -35,11 +36,14 @@ export type SessionMeta = {
35
36
  source?: string;
36
37
  originator?: string;
37
38
  cli_version?: string;
39
+ thread_source?: string;
40
+ model_provider?: string;
38
41
  };
39
42
 
40
43
  export type MessageRecord = {
41
44
  role: string;
42
45
  content: JsonValue;
46
+ model?: string | null;
43
47
  phase?: string;
44
48
  };
45
49
 
@@ -77,6 +81,10 @@ export type ThreadRow = {
77
81
  model: string | null;
78
82
  reasoning_effort: string | null;
79
83
  agent_path: string | null;
84
+ created_at_ms: number | null;
85
+ updated_at_ms: number | null;
86
+ thread_source: string | null;
87
+ preview: string;
80
88
  };
81
89
 
82
90
  export type SpawnEdgeRow = {
@@ -0,0 +1,58 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { ParsedCodexTranscript } from './codex-browser-types';
4
+ import { parseCodexTranscriptFile } from './codex-thread-parser';
5
+ import { getFileFingerprint, hashCacheKeyParts, withCachedJson } from './ui-cache';
6
+
7
+ export const LARGE_THREAD_SIZE_BYTES = 100 * 1024 * 1024;
8
+ export const LARGE_THREAD_PREVIEW_EVENT_LIMIT = 200;
9
+
10
+ export const getCachedParsedCodexTranscript = async (sessionFile: string): Promise<ParsedCodexTranscript> => {
11
+ const fingerprint = await getFileFingerprint(sessionFile);
12
+ const key = `thread-${hashCacheKeyParts(path.basename(sessionFile), fingerprint)}`;
13
+
14
+ return withCachedJson(key, async () => parseCodexTranscriptFile(sessionFile));
15
+ };
16
+
17
+ type CachedThreadTranscriptPreviewOptions = {
18
+ largeTranscriptThresholdBytes?: number;
19
+ previewEventLimit?: number;
20
+ };
21
+
22
+ export const getThreadRolloutLoadState = async (
23
+ sessionFile: string,
24
+ largeTranscriptThresholdBytes = LARGE_THREAD_SIZE_BYTES,
25
+ ) => {
26
+ const metadata = await stat(sessionFile);
27
+
28
+ return {
29
+ fileSizeBytes: metadata.size,
30
+ shouldDeferTranscriptLoad: metadata.size > largeTranscriptThresholdBytes,
31
+ };
32
+ };
33
+
34
+ export const getCachedThreadTranscriptPreview = async (
35
+ sessionFile: string,
36
+ options: CachedThreadTranscriptPreviewOptions = {},
37
+ ): Promise<ParsedCodexTranscript> => {
38
+ const threshold = options.largeTranscriptThresholdBytes ?? LARGE_THREAD_SIZE_BYTES;
39
+ const previewEventLimit = options.previewEventLimit ?? LARGE_THREAD_PREVIEW_EVENT_LIMIT;
40
+ const fingerprint = await getFileFingerprint(sessionFile);
41
+ const { fileSizeBytes, shouldDeferTranscriptLoad } = await getThreadRolloutLoadState(sessionFile, threshold);
42
+ const key = `thread-preview-${hashCacheKeyParts(path.basename(sessionFile), fingerprint, String(threshold), String(previewEventLimit))}`;
43
+
44
+ return withCachedJson(key, async () => {
45
+ if (!shouldDeferTranscriptLoad) {
46
+ return parseCodexTranscriptFile(sessionFile, {
47
+ sourceFileSizeBytes: fileSizeBytes,
48
+ });
49
+ }
50
+
51
+ return parseCodexTranscriptFile(sessionFile, {
52
+ includeRaw: false,
53
+ maxEvents: previewEventLimit,
54
+ maxTurnContexts: 0,
55
+ sourceFileSizeBytes: fileSizeBytes,
56
+ });
57
+ });
58
+ };