spiracha 2.1.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 (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -6,14 +6,9 @@ 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
8
  import type { ExportFormat } from './shared';
9
- import {
10
- buildBatchExportBaseName,
11
- buildConversationExportBaseName,
12
- getExportMimeType,
13
- zipExportDirectory,
14
- zipExportFile,
15
- } from './ui-export-archive';
9
+ import { buildBatchExportBaseName, buildConversationExportBaseName, getExportMimeType } from './ui-export-archive';
16
10
  import { buildUiExportDownloadUrl, ensureUiExportDir } from './ui-export-files';
11
+ import { zipExportDirectory, zipExportFile } from './ui-export-zip';
17
12
 
18
13
  type RenderCodexThreadDownloadInput = {
19
14
  dbPath: 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,30 +1,80 @@
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
5
  import type { CodexTranscriptEventFilters } from './codex-transcript-filter';
6
6
  import { shouldShowCodexTranscriptEvent } from './codex-transcript-filter';
7
7
  import { runWithTranscriptLoadLimit } from './transcript-load-limiter';
8
- import { getFileFingerprint, hashCacheKeyParts, withCachedJson } from './ui-cache';
8
+ import { getFileFingerprint, hashCacheKeyPartsIterable, withCachedJson } from './ui-cache';
9
9
 
10
10
  // Keep initial thread payloads below sizes that make TanStack Start SSR responses unreliable.
11
11
  export const LARGE_THREAD_SIZE_BYTES = 8 * 1024 * 1024;
12
12
  export const LARGE_THREAD_PREVIEW_EVENT_LIMIT = 200;
13
- const CODEX_TRANSCRIPT_CACHE_VERSION = 'v2';
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>;
14
18
 
15
19
  const isMissingFileError = (error: unknown) => {
16
20
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
17
21
  };
18
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
+
19
39
  export const getCachedParsedCodexTranscript = async (sessionFile: string): Promise<ParsedCodexTranscript> => {
20
- const fingerprint = await getFileFingerprint(sessionFile);
21
- const key = `thread-${hashCacheKeyParts(CODEX_TRANSCRIPT_CACHE_VERSION, 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
+ };
51
+
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
+ };
22
60
 
23
- return withCachedJson(key, async () =>
24
- runWithTranscriptLoadLimit(() => parseCodexTranscriptFile(sessionFile), {
25
- path: sessionFile,
26
- source: 'codex-full',
27
- }),
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
+ }),
28
78
  );
29
79
  };
30
80
 
@@ -65,38 +115,43 @@ export const getCachedThreadTranscriptPreview = async (
65
115
  const threshold = options.largeTranscriptThresholdBytes ?? LARGE_THREAD_SIZE_BYTES;
66
116
  const previewEventLimit = options.previewEventLimit ?? LARGE_THREAD_PREVIEW_EVENT_LIMIT;
67
117
  const filters = options.filters;
68
- const fingerprint = await getFileFingerprint(sessionFile);
69
- const { fileSizeBytes, shouldDeferTranscriptLoad } = await getThreadRolloutLoadState(sessionFile, threshold);
70
118
  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)}`;
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
+ }
72
140
 
73
- return withCachedJson(key, async () => {
74
- if (!shouldDeferTranscriptLoad) {
75
141
  return runWithTranscriptLoadLimit(
76
142
  () =>
77
143
  parseCodexTranscriptFile(sessionFile, {
144
+ eventFilter: filters ? (event) => shouldShowCodexTranscriptEvent(event, filters) : undefined,
145
+ includeRaw: false,
146
+ maxTurnContexts: 0,
78
147
  sourceFileSizeBytes: fileSizeBytes,
148
+ tailEventLimit: previewEventLimit,
79
149
  }),
80
150
  {
81
151
  path: sessionFile,
82
- source: 'codex-preview-full',
152
+ source: 'codex-preview',
83
153
  },
84
154
  );
85
- }
86
-
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
- );
101
- });
155
+ },
156
+ );
102
157
  };
@@ -33,7 +33,9 @@ type ParseCodexTranscriptState = {
33
33
  maxTurnContexts: number;
34
34
  sequence: number;
35
35
  shouldStop: boolean;
36
+ tailEventCursor: number;
36
37
  tailEventLimit: number;
38
+ tailEventsWrapped: boolean;
37
39
  turnContexts: TurnContextRecord[];
38
40
  };
39
41
 
@@ -88,7 +90,9 @@ export const parseCodexTranscriptFile = async (
88
90
  maxTurnContexts,
89
91
  sequence: 0,
90
92
  shouldStop: false,
93
+ tailEventCursor: 0,
91
94
  tailEventLimit,
95
+ tailEventsWrapped: false,
92
96
  turnContexts,
93
97
  };
94
98
 
@@ -100,6 +104,15 @@ export const parseCodexTranscriptFile = async (
100
104
  }
101
105
  }
102
106
 
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
+ }
115
+
103
116
  for (const event of events) {
104
117
  updateTranscriptStats(stats, event);
105
118
  }
@@ -129,7 +142,7 @@ const captureTranscriptRecord = (parsed: Record<string, JsonValue>, state: Parse
129
142
  return;
130
143
  }
131
144
 
132
- const event = toThreadEvent(parsed, state.sequence, state.includeRaw);
145
+ const event = parseCodexTranscriptRecord(parsed, state.sequence, state.includeRaw);
133
146
  if (!event) {
134
147
  return;
135
148
  }
@@ -139,9 +152,12 @@ const captureTranscriptRecord = (parsed: Record<string, JsonValue>, state: Parse
139
152
  return;
140
153
  }
141
154
 
142
- state.events.push(event);
143
- if (state.events.length > state.tailEventLimit) {
144
- state.events.shift();
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;
145
161
  }
146
162
  state.shouldStop = state.events.length >= state.maxEvents;
147
163
  };
@@ -204,10 +220,10 @@ const captureTurnContext = (parsed: Record<string, JsonValue>, turnContexts: Tur
204
220
  });
205
221
  };
206
222
 
207
- const toThreadEvent = (
223
+ export const parseCodexTranscriptRecord = (
208
224
  parsed: Record<string, JsonValue>,
209
- sequence: number,
210
- includeRaw: boolean,
225
+ sequence = 0,
226
+ includeRaw = false,
211
227
  ): ThreadEvent | null => {
212
228
  const payload = asObject(parsed.payload);
213
229
  if (!payload) {
@@ -235,15 +251,7 @@ const buildEventMessage = (
235
251
  sequence: number,
236
252
  timestamp: string | null,
237
253
  ) => {
238
- if (payloadType === 'task_started') {
239
- return createTaskStartedEvent(payload, raw, sequence, timestamp);
240
- }
241
-
242
- if (payloadType === 'task_complete') {
243
- return createTaskCompleteEvent(payload, raw, sequence, timestamp);
244
- }
245
-
246
- return null;
254
+ return buildSupplementalEvent(payload, payloadType, raw, sequence, timestamp);
247
255
  };
248
256
 
249
257
  const buildResponseItemEvent = (
@@ -265,11 +273,11 @@ const buildResponseItemEvent = (
265
273
  return createAgentMessageEvent(payload, raw, sequence, timestamp);
266
274
  }
267
275
 
268
- if (payloadType === 'function_call') {
269
- 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');
270
278
  }
271
279
 
272
- if (payloadType === 'function_call_output') {
280
+ if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
273
281
  return createToolOutputEvent(payload, raw, sequence, timestamp);
274
282
  }
275
283
 
@@ -277,6 +285,16 @@ const buildResponseItemEvent = (
277
285
  return createReasoningEvent(payload, raw, sequence, timestamp);
278
286
  }
279
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
+ ) => {
280
298
  if (payloadType === 'token_count') {
281
299
  return createTokenCountEvent(payload, raw, sequence, timestamp);
282
300
  }
@@ -305,12 +323,12 @@ const createMessageEvent = (
305
323
  const role = asString(payload.role);
306
324
  const content = payload.content;
307
325
  const text = extractText(content);
308
- if (!role || content === undefined) {
326
+ if (!role || content === undefined || !text.trim()) {
309
327
  return null;
310
328
  }
311
329
 
312
330
  return {
313
- isHiddenByDefault: shouldHideTranscriptText(role, text),
331
+ isHiddenByDefault: shouldHideCodexTranscriptText(role, text),
314
332
  kind: 'message',
315
333
  memoryCitation: null,
316
334
  model: asString(payload.model),
@@ -330,9 +348,9 @@ const createUserMessageEvent = (
330
348
  sequence: number,
331
349
  timestamp: string | null,
332
350
  ): MessageEvent => {
333
- const text = stripMemoryCitationBlocks(asString(payload.message) ?? '');
351
+ const text = stripCodexMemoryCitationBlocks(asString(payload.message) ?? '');
334
352
  return {
335
- isHiddenByDefault: shouldHideTranscriptText('user', text),
353
+ isHiddenByDefault: shouldHideCodexTranscriptText('user', text),
336
354
  kind: 'message',
337
355
  memoryCitation: null,
338
356
  model: null,
@@ -352,9 +370,9 @@ const createAgentMessageEvent = (
352
370
  sequence: number,
353
371
  timestamp: string | null,
354
372
  ): MessageEvent => {
355
- const text = stripMemoryCitationBlocks(asString(payload.message) ?? '');
373
+ const text = stripCodexMemoryCitationBlocks(asString(payload.message) ?? '');
356
374
  return {
357
- isHiddenByDefault: shouldHideTranscriptText('assistant', text),
375
+ isHiddenByDefault: shouldHideCodexTranscriptText('assistant', text),
358
376
  kind: 'message',
359
377
  memoryCitation: payload.memory_citation ?? null,
360
378
  model: asString(payload.model),
@@ -373,10 +391,13 @@ const createToolCallEvent = (
373
391
  raw: Record<string, JsonValue>,
374
392
  sequence: number,
375
393
  timestamp: string | null,
394
+ isCustomToolCall: boolean,
376
395
  ): ToolCallEvent => {
377
396
  const name = asString(payload.name) ?? 'unknown';
378
- const argumentsText = asString(payload.arguments);
379
- 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);
380
401
 
381
402
  return {
382
403
  argumentsParseFailed: parsedArguments.argumentsParseFailed,
@@ -398,7 +419,7 @@ const createToolOutputEvent = (
398
419
  sequence: number,
399
420
  timestamp: string | null,
400
421
  ): ToolOutputEvent => {
401
- const outputText = asString(payload.output) ?? '';
422
+ const outputText = extractToolOutputText(payload.output);
402
423
 
403
424
  return {
404
425
  callId: asString(payload.call_id),
@@ -524,7 +545,7 @@ const updateTranscriptStats = (stats: ThreadTranscriptStats, event: ThreadEvent)
524
545
 
525
546
  if (event.kind === 'tool_call') {
526
547
  stats.toolCallCount += 1;
527
- if (event.name === 'exec_command') {
548
+ if (event.name === 'exec' || event.name === 'exec_command') {
528
549
  stats.execCommandCount += 1;
529
550
  }
530
551
  return;
@@ -592,13 +613,31 @@ const parseExecCommandArguments = (argumentsText: string | null) => {
592
613
  }
593
614
  };
594
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
+
595
634
  const extractText = (content: JsonValue): string => {
596
635
  if (typeof content === 'string') {
597
- return stripMemoryCitationBlocks(content);
636
+ return stripCodexMemoryCitationBlocks(content);
598
637
  }
599
638
 
600
639
  if (Array.isArray(content)) {
601
- return stripMemoryCitationBlocks(
640
+ return stripCodexMemoryCitationBlocks(
602
641
  content
603
642
  .map((entry) => extractTextPart(entry))
604
643
  .filter(Boolean)
@@ -607,14 +646,17 @@ const extractText = (content: JsonValue): string => {
607
646
  }
608
647
 
609
648
  if (content && typeof content === 'object') {
610
- return stripMemoryCitationBlocks(asString((content as Record<string, JsonValue>).text) ?? '');
649
+ return stripCodexMemoryCitationBlocks(asString((content as Record<string, JsonValue>).text) ?? '');
611
650
  }
612
651
 
613
652
  return '';
614
653
  };
615
654
 
616
- const stripMemoryCitationBlocks = (text: string): string => {
617
- return stripCodexAppDirectiveLines(text.replace(/<oai-mem-citation>[\s\S]*?<\/oai-mem-citation>/gu, ''));
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);
618
660
  };
619
661
 
620
662
  const extractTextPart = (entry: JsonValue): string => {
@@ -633,7 +675,7 @@ const extractTextPart = (entry: JsonValue): string => {
633
675
  return text ?? '';
634
676
  };
635
677
 
636
- const shouldHideTranscriptText = (role: string, text: string) => {
678
+ export const shouldHideCodexTranscriptText = (role: string, text: string) => {
637
679
  if (!text) {
638
680
  return true;
639
681
  }
@@ -644,6 +686,7 @@ const shouldHideTranscriptText = (role: string, text: string) => {
644
686
 
645
687
  return (
646
688
  text.startsWith('# AGENTS.md instructions for ') ||
689
+ text.startsWith('AGENTS.md instructions for ') ||
647
690
  text.startsWith('<permissions instructions>') ||
648
691
  text.startsWith('<app-context>') ||
649
692
  text.startsWith('<environment_context>') ||
@@ -1,8 +1,8 @@
1
1
  import { Database } from 'bun:sqlite';
2
- import { copyFile, utimes } from 'node:fs/promises';
2
+ import { copyFile, readdir, rm, utimes } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import type { RecoverProjectThreadsResult } from './codex-browser-types';
5
- import { getPortablePathBasename } from './shared';
5
+ import { getPortablePathBasename } from './portable-path';
6
6
  import { runWithSqliteRetry } from './sqlite-retry';
7
7
 
8
8
  type RecoveryThreadRow = {
@@ -18,6 +18,8 @@ type GlobalState = {
18
18
  'project-order'?: string[];
19
19
  };
20
20
 
21
+ const RECOVERY_BACKUP_RETENTION_COUNT = 5;
22
+
21
23
  const backupFile = async (filePath: string, label: string) => {
22
24
  const stamp = new Date()
23
25
  .toISOString()
@@ -26,6 +28,14 @@ const backupFile = async (filePath: string, label: string) => {
26
28
  .replace('T', '-');
27
29
  const backupPath = `${filePath}.bak-${label}-${stamp}`;
28
30
  await copyFile(filePath, backupPath);
31
+ const directory = path.dirname(filePath);
32
+ const prefix = `${path.basename(filePath)}.bak-${label}-`;
33
+ const backups = (await readdir(directory))
34
+ .filter((entry) => entry.startsWith(prefix))
35
+ .sort((left, right) => right.localeCompare(left));
36
+ await Promise.all(
37
+ backups.slice(RECOVERY_BACKUP_RETENTION_COUNT).map((entry) => rm(path.join(directory, entry), { force: true })),
38
+ );
29
39
  return backupPath;
30
40
  };
31
41
 
@@ -100,9 +110,9 @@ const refreshThreadRows = (db: Database, threadIds: string[]) => {
100
110
  return Number(result.changes);
101
111
  };
102
112
 
103
- const refreshSessionIndex = async (sessionIndexPath: string, threadIds: string[]) => {
113
+ const prepareSessionIndexRefresh = async (sessionIndexPath: string, threadIds: string[]) => {
104
114
  if (threadIds.length === 0) {
105
- return 0;
115
+ return { content: null, updated: 0 };
106
116
  }
107
117
 
108
118
  const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
@@ -116,7 +126,19 @@ const refreshSessionIndex = async (sessionIndexPath: string, threadIds: string[]
116
126
  continue;
117
127
  }
118
128
 
119
- const parsed = JSON.parse(line) as { id?: string; updated_at?: string };
129
+ let parsed: { id?: string; updated_at?: string };
130
+ try {
131
+ const value: unknown = JSON.parse(line);
132
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
133
+ rewrittenLines.push(line);
134
+ continue;
135
+ }
136
+ parsed = value as { id?: string; updated_at?: string };
137
+ } catch {
138
+ rewrittenLines.push(line);
139
+ continue;
140
+ }
141
+
120
142
  if (parsed.id && threadIdSet.has(parsed.id)) {
121
143
  parsed.updated_at = now;
122
144
  updated += 1;
@@ -125,8 +147,10 @@ const refreshSessionIndex = async (sessionIndexPath: string, threadIds: string[]
125
147
  rewrittenLines.push(JSON.stringify(parsed));
126
148
  }
127
149
 
128
- await Bun.write(sessionIndexPath, `${rewrittenLines.join('\n')}\n`);
129
- return updated;
150
+ return {
151
+ content: `${rewrittenLines.join('\n')}\n`,
152
+ updated,
153
+ };
130
154
  };
131
155
 
132
156
  const touchRolloutFiles = async (codexDir: string, rolloutPaths: string[]) => {
@@ -177,12 +201,15 @@ export const recoverCodexProjectThreads = async (
177
201
  const topLevelThreads = getProjectTopLevelThreads(db, projectName);
178
202
  const projectCwds = [...new Set(topLevelThreads.map((thread) => thread.cwd))];
179
203
  const rootUpdateResult = updateGlobalRoots(globalState, projectCwds);
180
- await writeGlobalState(globalStatePath, rootUpdateResult.state);
181
-
182
204
  const threadIds = topLevelThreads.map((thread) => thread.id);
183
205
  const rolloutPaths = topLevelThreads.map((thread) => thread.rollout_path);
206
+ const sessionIndexRefresh = await prepareSessionIndexRefresh(sessionIndexPath, threadIds);
207
+
208
+ await writeGlobalState(globalStatePath, rootUpdateResult.state);
184
209
  const threadDbRowsUpdated = refreshThreadRows(db, threadIds);
185
- const sessionIndexRowsUpdated = await refreshSessionIndex(sessionIndexPath, threadIds);
210
+ if (sessionIndexRefresh.content !== null) {
211
+ await Bun.write(sessionIndexPath, sessionIndexRefresh.content);
212
+ }
186
213
  const rolloutFilesTouched = await touchRolloutFiles(codexDir, rolloutPaths);
187
214
 
188
215
  return {
@@ -192,7 +219,7 @@ export const recoverCodexProjectThreads = async (
192
219
  resolvedCwds: projectCwds,
193
220
  rolloutFilesTouched,
194
221
  savedRootsAdded: rootUpdateResult.savedRootsAdded,
195
- sessionIndexRowsUpdated,
222
+ sessionIndexRowsUpdated: sessionIndexRefresh.updated,
196
223
  threadDbRowsUpdated,
197
224
  topLevelThreadsFound: threadIds.length,
198
225
  };