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
@@ -0,0 +1,605 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import type {
6
+ DashboardSummary,
7
+ DeleteProjectResult,
8
+ DeleteThreadsResult,
9
+ DynamicToolRow,
10
+ ProjectSummary,
11
+ ThreadBrowseData,
12
+ ThreadListEntry,
13
+ } from './codex-browser-types';
14
+ import type { ThreadRelations, ThreadRow } from './codex-exporter-types';
15
+ import { DEFAULT_CODEX_DIR, DEFAULT_DB_PATH } from './codex-exporter-types';
16
+ import { getCachedParsedCodexTranscript, getThreadRolloutLoadState } from './codex-thread-cache';
17
+ import { cleanInlineTitle, getPortablePathBasename } from './shared';
18
+ import { runWithSqliteRetry } from './sqlite-retry';
19
+ import { invalidateCacheByPrefix } from './ui-cache';
20
+
21
+ type DeleteThreadOptions = {
22
+ deleteSessionFiles?: boolean;
23
+ };
24
+
25
+ type DeleteProjectOptions = {
26
+ deleteSessionFiles?: boolean;
27
+ };
28
+
29
+ const SQLITE_DELETE_BATCH_SIZE = 400;
30
+ const SESSION_FILE_DELETE_CONCURRENCY = 16;
31
+ const THREAD_LIST_IO_CONCURRENCY = 8;
32
+
33
+ const chunkValues = <T>(values: T[], chunkSize: number) => {
34
+ const chunks: T[][] = [];
35
+
36
+ for (let index = 0; index < values.length; index += chunkSize) {
37
+ chunks.push(values.slice(index, index + chunkSize));
38
+ }
39
+
40
+ return chunks;
41
+ };
42
+
43
+ const isPromiseLike = (value: unknown): value is PromiseLike<unknown> => {
44
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
45
+ return false;
46
+ }
47
+
48
+ return 'then' in value && typeof value.then === 'function';
49
+ };
50
+
51
+ const mapWithConcurrency = async <T, TResult>(
52
+ values: T[],
53
+ limit: number,
54
+ mapper: (value: T, index: number) => Promise<TResult>,
55
+ ) => {
56
+ const results = new Array<TResult>(values.length);
57
+ let nextIndex = 0;
58
+
59
+ const worker = async () => {
60
+ while (true) {
61
+ const currentIndex = nextIndex;
62
+ nextIndex += 1;
63
+
64
+ if (currentIndex >= values.length) {
65
+ return;
66
+ }
67
+
68
+ results[currentIndex] = await mapper(values[currentIndex]!, currentIndex);
69
+ }
70
+ };
71
+
72
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => worker()));
73
+ return results;
74
+ };
75
+
76
+ const openReadonlyDb = (dbPath: string, busyTimeoutMs: number) => {
77
+ const db = new Database(dbPath, { readonly: true });
78
+ try {
79
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
80
+ return db;
81
+ } catch (error) {
82
+ db.close();
83
+ throw error;
84
+ }
85
+ };
86
+
87
+ const openWritableDb = (dbPath: string, busyTimeoutMs: number) => {
88
+ const db = new Database(dbPath);
89
+ try {
90
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
91
+ return db;
92
+ } catch (error) {
93
+ db.close();
94
+ throw error;
95
+ }
96
+ };
97
+
98
+ const toTimestampMs = (thread: ThreadRow) => {
99
+ return thread.updated_at_ms ?? thread.updated_at * 1000;
100
+ };
101
+
102
+ const parseDynamicToolRow = (row: Record<string, number | string | null>): DynamicToolRow => {
103
+ return {
104
+ deferLoading: Number(row.defer_loading ?? 0) === 1,
105
+ description: String(row.description ?? ''),
106
+ inputSchema: parseJsonSafely(typeof row.input_schema === 'string' ? row.input_schema : null),
107
+ name: String(row.name ?? 'unknown'),
108
+ namespace: typeof row.namespace === 'string' ? row.namespace : null,
109
+ position: Number(row.position ?? 0),
110
+ threadId: String(row.thread_id),
111
+ };
112
+ };
113
+
114
+ const parseJsonSafely = (value: string | null) => {
115
+ if (!value) {
116
+ return null;
117
+ }
118
+
119
+ try {
120
+ return JSON.parse(value) as DynamicToolRow['inputSchema'];
121
+ } catch {
122
+ return null;
123
+ }
124
+ };
125
+
126
+ export const withReadonlyDb = <T>(dbPath: string, callback: (db: Database) => T): T => {
127
+ return runWithSqliteRetry({
128
+ action: () => {
129
+ const db = openReadonlyDb(dbPath, 5000);
130
+ try {
131
+ const result = callback(db);
132
+ if (isPromiseLike(result)) {
133
+ throw new Error('Database callbacks must be synchronous');
134
+ }
135
+
136
+ return result;
137
+ } finally {
138
+ db.close();
139
+ }
140
+ },
141
+ });
142
+ };
143
+
144
+ const withWritableDb = <T>(dbPath: string, callback: (db: Database) => T): T => {
145
+ const db = runWithSqliteRetry({
146
+ action: () => {
147
+ return openWritableDb(dbPath, 5000);
148
+ },
149
+ });
150
+ try {
151
+ const result = callback(db);
152
+ if (isPromiseLike(result)) {
153
+ throw new Error('Database callbacks must be synchronous');
154
+ }
155
+
156
+ return result;
157
+ } finally {
158
+ db.close();
159
+ }
160
+ };
161
+
162
+ export const resolveCodexThreadDbPath = () => {
163
+ const configuredDbPath = process.env.SPIRACHA_CODEX_DB?.trim();
164
+ if (configuredDbPath) {
165
+ return configuredDbPath;
166
+ }
167
+
168
+ const candidates = [
169
+ DEFAULT_DB_PATH,
170
+ path.join(DEFAULT_CODEX_DIR, 'sqlite', 'state_5.sqlite'),
171
+ path.join(os.homedir(), '.codex', 'state_5.sqlite'),
172
+ ];
173
+
174
+ for (const candidate of candidates) {
175
+ try {
176
+ const db = runWithSqliteRetry({
177
+ action: () => {
178
+ return openReadonlyDb(candidate, 1500);
179
+ },
180
+ });
181
+ db.close();
182
+ return candidate;
183
+ } catch {}
184
+ }
185
+
186
+ throw new Error(`Unable to open Codex thread database. Tried: ${candidates.join(', ')}`);
187
+ };
188
+
189
+ const readAllThreads = (dbPath: string): ThreadRow[] => {
190
+ return withReadonlyDb(dbPath, (db) => {
191
+ return db
192
+ .query('SELECT * FROM threads ORDER BY COALESCE(updated_at_ms, updated_at * 1000) DESC, id DESC')
193
+ .all() as ThreadRow[];
194
+ });
195
+ };
196
+
197
+ const filterThreadsByProject = (threads: ThreadRow[], projectName: string | null) => {
198
+ if (!projectName) {
199
+ return threads;
200
+ }
201
+
202
+ return threads.filter((thread) => getPortablePathBasename(thread.cwd) === projectName);
203
+ };
204
+
205
+ const buildProjectSummaryMap = (threads: ThreadRow[]) => {
206
+ const projectMap = new Map<
207
+ string,
208
+ {
209
+ archivedThreadCount: number;
210
+ cwdPaths: Set<string>;
211
+ lastUpdatedAtMs: number | null;
212
+ modelNames: Set<string>;
213
+ name: string;
214
+ threadCount: number;
215
+ totalTokens: number;
216
+ }
217
+ >();
218
+
219
+ for (const thread of threads) {
220
+ const projectName = getPortablePathBasename(thread.cwd);
221
+ if (!projectName) {
222
+ continue;
223
+ }
224
+
225
+ const current = projectMap.get(projectName) ?? {
226
+ archivedThreadCount: 0,
227
+ cwdPaths: new Set<string>(),
228
+ lastUpdatedAtMs: null,
229
+ modelNames: new Set<string>(),
230
+ name: projectName,
231
+ threadCount: 0,
232
+ totalTokens: 0,
233
+ };
234
+ current.archivedThreadCount += thread.archived ? 1 : 0;
235
+ current.cwdPaths.add(thread.cwd);
236
+ current.lastUpdatedAtMs = Math.max(current.lastUpdatedAtMs ?? 0, toTimestampMs(thread));
237
+ if (thread.model) {
238
+ current.modelNames.add(thread.model);
239
+ }
240
+ current.threadCount += 1;
241
+ current.totalTokens += thread.tokens_used;
242
+ projectMap.set(projectName, current);
243
+ }
244
+
245
+ return projectMap;
246
+ };
247
+
248
+ const mapProjectSummaries = (projectMap: ReturnType<typeof buildProjectSummaryMap>): ProjectSummary[] => {
249
+ return [...projectMap.values()]
250
+ .map((project) => {
251
+ return {
252
+ archivedThreadCount: project.archivedThreadCount,
253
+ cwdPaths: [...project.cwdPaths].sort(),
254
+ lastUpdatedAtMs: project.lastUpdatedAtMs,
255
+ modelNames: [...project.modelNames].sort(),
256
+ name: project.name,
257
+ threadCount: project.threadCount,
258
+ totalTokens: project.totalTokens,
259
+ };
260
+ })
261
+ .sort((left, right) => {
262
+ if (left.totalTokens !== right.totalTokens) {
263
+ return right.totalTokens - left.totalTokens;
264
+ }
265
+
266
+ return left.name.localeCompare(right.name);
267
+ });
268
+ };
269
+
270
+ const getRelationsForThread = (db: Database, threadId: string, existingTableNames: Set<string>): ThreadRelations => {
271
+ if (!existingTableNames.has('thread_spawn_edges')) {
272
+ return {
273
+ childEdges: [],
274
+ parentThreadId: null,
275
+ };
276
+ }
277
+
278
+ const parentRow = db
279
+ .query(
280
+ 'SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges WHERE child_thread_id = ? LIMIT 1',
281
+ )
282
+ .get(threadId) as {
283
+ child_thread_id: string;
284
+ parent_thread_id: string;
285
+ status: string;
286
+ } | null;
287
+ const childRows = db
288
+ .query(
289
+ 'SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges WHERE parent_thread_id = ? ORDER BY child_thread_id ASC',
290
+ )
291
+ .all(threadId) as Array<{
292
+ child_thread_id: string;
293
+ parent_thread_id: string;
294
+ status: string;
295
+ }>;
296
+
297
+ return {
298
+ childEdges: childRows,
299
+ parentThreadId: parentRow?.parent_thread_id ?? null,
300
+ };
301
+ };
302
+
303
+ const getExistingTableNames = (db: Database) => {
304
+ const rows = db.query('SELECT name FROM sqlite_master WHERE type = ?').all('table') as Array<{ name: string }>;
305
+ return new Set(rows.map((row) => row.name));
306
+ };
307
+
308
+ const getThreadDeleteTargets = (db: Database, threadIds: string[]) => {
309
+ if (threadIds.length === 0) {
310
+ return [];
311
+ }
312
+
313
+ const targets: Array<{ id: string; rollout_path: string }> = [];
314
+
315
+ for (const threadIdChunk of chunkValues(threadIds, SQLITE_DELETE_BATCH_SIZE)) {
316
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
317
+ targets.push(
318
+ ...(db
319
+ .query(`SELECT id, rollout_path FROM threads WHERE id IN (${placeholders})`)
320
+ .all(...threadIdChunk) as Array<{
321
+ id: string;
322
+ rollout_path: string;
323
+ }>),
324
+ );
325
+ }
326
+
327
+ return targets;
328
+ };
329
+
330
+ const deleteThreadIds = (db: Database, threadIds: string[]): DeleteThreadsResult => {
331
+ if (threadIds.length === 0) {
332
+ return {
333
+ deletedSessionFiles: [],
334
+ deletedThreadIds: [],
335
+ };
336
+ }
337
+
338
+ const existingTableNames = getExistingTableNames(db);
339
+ const threadTargets = getThreadDeleteTargets(db, threadIds);
340
+ const existingIds = threadTargets.map((target) => target.id);
341
+
342
+ if (existingIds.length === 0) {
343
+ return {
344
+ deletedSessionFiles: [],
345
+ deletedThreadIds: [],
346
+ };
347
+ }
348
+
349
+ const deleteMany = db.transaction((ids: string[]) => {
350
+ for (const threadIdChunk of chunkValues(ids, SQLITE_DELETE_BATCH_SIZE)) {
351
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
352
+
353
+ // Codex schema differs across versions, so only touch dependent tables that actually exist.
354
+ if (existingTableNames.has('thread_dynamic_tools')) {
355
+ db.query(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
356
+ }
357
+
358
+ if (existingTableNames.has('thread_goals')) {
359
+ db.query(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
360
+ }
361
+
362
+ if (existingTableNames.has('stage1_outputs')) {
363
+ db.query(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
364
+ }
365
+
366
+ if (existingTableNames.has('thread_spawn_edges')) {
367
+ db.query(
368
+ `DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
369
+ ).run(...threadIdChunk, ...threadIdChunk);
370
+ }
371
+
372
+ db.query(`DELETE FROM threads WHERE id IN (${placeholders})`).run(...threadIdChunk);
373
+ }
374
+ });
375
+
376
+ deleteMany(existingIds);
377
+
378
+ return {
379
+ deletedSessionFiles: threadTargets.map((target) => target.rollout_path),
380
+ deletedThreadIds: existingIds,
381
+ };
382
+ };
383
+
384
+ const deleteThreadSessionFiles = async (sessionFiles: string[]) => {
385
+ const uniqueSessionFiles = [...new Set(sessionFiles)];
386
+ await mapWithConcurrency(uniqueSessionFiles, SESSION_FILE_DELETE_CONCURRENCY, async (sessionFile) => {
387
+ await rm(sessionFile, { force: true });
388
+ return sessionFile;
389
+ });
390
+ return uniqueSessionFiles;
391
+ };
392
+
393
+ export const listCodexProjects = (dbPath: string): ProjectSummary[] => {
394
+ return mapProjectSummaries(buildProjectSummaryMap(readAllThreads(dbPath)));
395
+ };
396
+
397
+ type ListProjectThreadsOptions = {
398
+ largeTranscriptThresholdBytes?: number;
399
+ };
400
+
401
+ const compactThreadListRow = (thread: ThreadRow): ThreadRow => {
402
+ return {
403
+ ...thread,
404
+ preview: cleanInlineTitle(thread.preview || thread.first_user_message || ''),
405
+ title: cleanInlineTitle(thread.title),
406
+ };
407
+ };
408
+
409
+ export const listProjectThreads = async (
410
+ dbPath: string,
411
+ projectName: string,
412
+ options: ListProjectThreadsOptions = {},
413
+ ): Promise<ThreadListEntry[]> => {
414
+ const threads = filterThreadsByProject(readAllThreads(dbPath), projectName);
415
+ const entries = await mapWithConcurrency(threads, THREAD_LIST_IO_CONCURRENCY, async (thread) => {
416
+ const rollout = await getThreadRolloutLoadState(thread.rollout_path, options.largeTranscriptThresholdBytes);
417
+
418
+ if (rollout.shouldDeferTranscriptLoad) {
419
+ return {
420
+ project: projectName,
421
+ rolloutSizeBytes: rollout.fileSizeBytes,
422
+ stats: {
423
+ deferred: true,
424
+ execCommandCount: 0,
425
+ toolCallCount: 0,
426
+ webSearchEventCount: 0,
427
+ },
428
+ thread: compactThreadListRow(thread),
429
+ };
430
+ }
431
+
432
+ const transcript = await getCachedParsedCodexTranscript(thread.rollout_path);
433
+
434
+ return {
435
+ project: projectName,
436
+ rolloutSizeBytes: rollout.fileSizeBytes,
437
+ stats: {
438
+ deferred: false,
439
+ execCommandCount: transcript.stats.execCommandCount,
440
+ toolCallCount: transcript.stats.toolCallCount,
441
+ webSearchEventCount: transcript.stats.webSearchEventCount,
442
+ },
443
+ thread: compactThreadListRow(thread),
444
+ };
445
+ });
446
+
447
+ return entries.sort((left, right) => toTimestampMs(right.thread) - toTimestampMs(left.thread));
448
+ };
449
+
450
+ export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBrowseData => {
451
+ return withReadonlyDb(dbPath, (db) => {
452
+ const existingTableNames = getExistingTableNames(db);
453
+ const thread = db.query('SELECT * FROM threads WHERE id = ? LIMIT 1').get(threadId) as ThreadRow | null;
454
+ if (!thread) {
455
+ throw new Error(`Thread not found: ${threadId}`);
456
+ }
457
+
458
+ const dynamicTools = existingTableNames.has('thread_dynamic_tools')
459
+ ? (db
460
+ .query(
461
+ 'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
462
+ )
463
+ .all(threadId) as Array<Record<string, number | string | null>>)
464
+ : [];
465
+
466
+ return {
467
+ dynamicTools: dynamicTools.map((row) => parseDynamicToolRow(row)),
468
+ project: getPortablePathBasename(thread.cwd),
469
+ relations: getRelationsForThread(db, threadId, existingTableNames),
470
+ thread,
471
+ };
472
+ });
473
+ };
474
+
475
+ export const getCodexDashboardSummary = (dbPath: string): DashboardSummary => {
476
+ const threads = readAllThreads(dbPath);
477
+ const projects = mapProjectSummaries(buildProjectSummaryMap(threads));
478
+ const threadsWithRelations = withReadonlyDb(dbPath, (db) => {
479
+ if (!getExistingTableNames(db).has('thread_spawn_edges')) {
480
+ return 0;
481
+ }
482
+
483
+ const rows = db.query('SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges').all() as Array<{
484
+ child_thread_id: string;
485
+ parent_thread_id: string;
486
+ }>;
487
+ const relatedThreadIds = new Set(rows.flatMap((row) => [row.parent_thread_id, row.child_thread_id]));
488
+ return relatedThreadIds.size;
489
+ });
490
+
491
+ return {
492
+ activeThreads: threads.filter((thread) => !thread.archived).length,
493
+ archivedThreads: threads.filter((thread) => Boolean(thread.archived)).length,
494
+ recentThreads: threads.slice(0, 5),
495
+ threadsWithRelations,
496
+ topProjectsByThreadCount: [...projects]
497
+ .sort((left, right) => {
498
+ if (left.threadCount !== right.threadCount) {
499
+ return right.threadCount - left.threadCount;
500
+ }
501
+
502
+ return left.name.localeCompare(right.name);
503
+ })
504
+ .slice(0, 5),
505
+ topProjectsByTokens: projects.slice(0, 5),
506
+ totalProjects: projects.length,
507
+ totalThreads: threads.length,
508
+ totalTokens: threads.reduce((sum, thread) => sum + thread.tokens_used, 0),
509
+ };
510
+ };
511
+
512
+ export const deleteCodexThread = async (
513
+ dbPath: string,
514
+ threadId: string,
515
+ options: DeleteThreadOptions = {},
516
+ ): Promise<DeleteThreadsResult> => {
517
+ const result = withWritableDb(dbPath, (db) => {
518
+ return deleteThreadIds(db, [threadId]);
519
+ });
520
+
521
+ try {
522
+ if (options.deleteSessionFiles) {
523
+ return {
524
+ ...result,
525
+ deletedSessionFiles: await deleteThreadSessionFiles(result.deletedSessionFiles),
526
+ };
527
+ }
528
+
529
+ return {
530
+ ...result,
531
+ deletedSessionFiles: [],
532
+ };
533
+ } finally {
534
+ await invalidateCodexUiCaches();
535
+ }
536
+ };
537
+
538
+ export const deleteCodexThreads = async (
539
+ dbPath: string,
540
+ threadIds: string[],
541
+ options: DeleteThreadOptions = {},
542
+ ): Promise<DeleteThreadsResult> => {
543
+ const result = withWritableDb(dbPath, (db) => {
544
+ return deleteThreadIds(db, threadIds);
545
+ });
546
+
547
+ try {
548
+ if (options.deleteSessionFiles) {
549
+ return {
550
+ ...result,
551
+ deletedSessionFiles: await deleteThreadSessionFiles(result.deletedSessionFiles),
552
+ };
553
+ }
554
+
555
+ return {
556
+ ...result,
557
+ deletedSessionFiles: [],
558
+ };
559
+ } finally {
560
+ await invalidateCodexUiCaches();
561
+ }
562
+ };
563
+
564
+ export const deleteCodexProject = async (
565
+ dbPath: string,
566
+ projectName: string,
567
+ options: DeleteProjectOptions = {},
568
+ ): Promise<DeleteProjectResult> => {
569
+ const result = withWritableDb(dbPath, (db) => {
570
+ const threads = db.query('SELECT id, cwd FROM threads').all() as Array<{ cwd: string; id: string }>;
571
+ const threadIds = threads
572
+ .filter((thread) => getPortablePathBasename(thread.cwd) === projectName)
573
+ .map((thread) => thread.id);
574
+ const deleted = deleteThreadIds(db, threadIds);
575
+
576
+ return {
577
+ ...deleted,
578
+ projectName,
579
+ };
580
+ });
581
+
582
+ try {
583
+ if (options.deleteSessionFiles) {
584
+ return {
585
+ ...result,
586
+ deletedSessionFiles: await deleteThreadSessionFiles(result.deletedSessionFiles),
587
+ };
588
+ }
589
+
590
+ return {
591
+ ...result,
592
+ deletedSessionFiles: [],
593
+ };
594
+ } finally {
595
+ await invalidateCodexUiCaches();
596
+ }
597
+ };
598
+
599
+ export const listScopedThreads = (dbPath: string, projectName: string | null): ThreadRow[] => {
600
+ return filterThreadsByProject(readAllThreads(dbPath), projectName);
601
+ };
602
+
603
+ export const invalidateCodexUiCaches = async () => {
604
+ await invalidateCacheByPrefix('analytics-', 'thread-');
605
+ };