spiracha 1.3.3 → 1.4.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 (158) hide show
  1. package/AGENTS.md +7 -1
  2. package/README.md +6 -0
  3. package/apps/ui/AGENTS.md +8 -0
  4. package/apps/ui/README.md +10 -3
  5. package/apps/ui/dist/client/assets/analytics-DK2jdvmI.js +1 -0
  6. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-DmD0sN8X.js +7 -0
  7. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-KAcXtNxI.js +1 -0
  8. package/apps/ui/dist/client/assets/antigravity-keychain-panel-BRHVjFAM.js +1 -0
  9. package/apps/ui/dist/client/assets/antigravity._workspaceKey-ByitGWki.js +1 -0
  10. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CGV7amas.js +1 -0
  11. package/apps/ui/dist/client/assets/antigravity.index-DEEkjlZa.js +1 -0
  12. package/apps/ui/dist/client/assets/antigravity.index-DgW25FEV.js +1 -0
  13. package/apps/ui/dist/client/assets/badge-DfWvPfKF.js +1 -0
  14. package/apps/ui/dist/client/assets/checkbox-CdHitXWQ.js +1 -0
  15. package/apps/ui/dist/client/assets/createServerFn-CgRRVpBH.js +3 -0
  16. package/apps/ui/dist/client/assets/cursor-threads._composerId-CmBFclK8.js +1 -0
  17. package/apps/ui/dist/client/assets/cursor-threads._composerId-Ti2gTm63.js +1 -0
  18. package/apps/ui/dist/client/assets/cursor._workspaceKey-CaAZjGmm.js +1 -0
  19. package/apps/ui/dist/client/assets/cursor._workspaceKey-DzD_c6j9.js +1 -0
  20. package/apps/ui/dist/client/assets/cursor.index-CrnMwDCi.js +1 -0
  21. package/apps/ui/dist/client/assets/cursor.index-PkCFVcl4.js +2 -0
  22. package/apps/ui/dist/client/assets/{data-table-DeQq8xdt.js → data-table-Br78_dk3.js} +2 -2
  23. package/apps/ui/dist/client/assets/delete-confirm-dialog-BHRmyLHr.js +11 -0
  24. package/apps/ui/dist/client/assets/dist-B9pyYCVU.js +1 -0
  25. package/apps/ui/dist/client/assets/dist-BhZOxAPP.js +1 -0
  26. package/apps/ui/dist/client/assets/dist-DvMS2965.js +1 -0
  27. package/apps/ui/dist/client/assets/download-Bf0QJKD3.js +1 -0
  28. package/apps/ui/dist/client/assets/dropdown-menu-Dy5cLzTy.js +1 -0
  29. package/apps/ui/dist/client/assets/es2015-BkCWttyM.js +41 -0
  30. package/apps/ui/dist/client/assets/export-dialog-C86SYHJn.js +1 -0
  31. package/apps/ui/dist/client/assets/formatters-BM9kB7ed.js +1 -0
  32. package/apps/ui/dist/client/assets/index-CPvAP-jk.js +149 -0
  33. package/apps/ui/dist/client/assets/jsx-runtime-DGeXAQPT.js +1 -0
  34. package/apps/ui/dist/client/assets/metric-card-Dplm0ZiM.js +1 -0
  35. package/apps/ui/dist/client/assets/page-header-Cwa3p6Tl.js +1 -0
  36. package/apps/ui/dist/client/assets/projects._project-Ckf6muWZ.js +1 -0
  37. package/apps/ui/dist/client/assets/projects._project-DSDwH4NF.js +1 -0
  38. package/apps/ui/dist/client/assets/projects.index-DEfhWqa8.js +1 -0
  39. package/apps/ui/dist/client/assets/projects.index-V5ibNZII.js +3 -0
  40. package/apps/ui/dist/client/assets/react-dom-DL96Jor4.js +1 -0
  41. package/apps/ui/dist/client/assets/refresh-ccw-BgKjOiA6.js +1 -0
  42. package/apps/ui/dist/client/assets/reload-error-panel-AHqu0akK.js +1 -0
  43. package/apps/ui/dist/client/assets/routes-Df2tgpyd.js +1 -0
  44. package/apps/ui/dist/client/assets/scroll-text-fM0sMOB3.js +1 -0
  45. package/apps/ui/dist/client/assets/select-Bf2GwfPR.js +1 -0
  46. package/apps/ui/dist/client/assets/settings-5VQIzME0.js +1 -0
  47. package/apps/ui/dist/client/assets/sqlite-error-B6uZjUe8.js +1 -0
  48. package/apps/ui/dist/client/assets/styles-BhRkXgwB.css +1 -0
  49. package/apps/ui/dist/client/assets/tabs-OO4VU5KR.js +7 -0
  50. package/apps/ui/dist/client/assets/text-filter-DVAZySUS.js +2 -0
  51. package/apps/ui/dist/client/assets/threads._threadId-BB9tTsV4.js +1 -0
  52. package/apps/ui/dist/client/assets/threads._threadId-BgDhFj2I.js +1 -0
  53. package/apps/ui/dist/client/assets/useMutation-CC_B7uy5.js +1 -0
  54. package/apps/ui/dist/client/assets/useQuery-C5By6WKU.js +1 -0
  55. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CRW2kqj0.js +232 -0
  56. package/apps/ui/dist/server/assets/analytics-CYP_LT5Y.js +16 -0
  57. package/apps/ui/dist/server/assets/{analytics-Dc_QDZZC.js → analytics-CtpOMVMO.js} +13 -6
  58. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-BtexWY-K.js +613 -0
  59. package/apps/ui/dist/server/assets/{antigravity-conversations._conversationId-C9z4AeN-.js → antigravity-conversations._conversationId-zMrR6v6R.js} +2 -2
  60. package/apps/ui/dist/server/assets/{antigravity-db-D9gW1D8G.js → antigravity-db-Bh8_U9uw.js} +11 -7
  61. package/apps/ui/dist/server/assets/{antigravity-keychain-panel-bpPoxhyp.js → antigravity-keychain-panel-CC7FLmAK.js} +1 -1
  62. package/apps/ui/dist/server/assets/{antigravity-queries-DVIKyOXi.js → antigravity-queries-CGrJO9Vr.js} +5 -5
  63. package/apps/ui/dist/server/assets/{antigravity-server-Cp1lc8fp.js → antigravity-server-CHRjVFqN.js} +11 -11
  64. package/apps/ui/dist/server/assets/{antigravity._workspaceKey-Dl8o1ptg.js → antigravity._workspaceKey-B0QeYUOh.js} +2 -2
  65. package/apps/ui/dist/server/assets/{antigravity._workspaceKey-BAoa4izU.js → antigravity._workspaceKey-C3kP-qyN.js} +4 -4
  66. package/apps/ui/dist/server/assets/{antigravity.index-BOlrJasZ.js → antigravity.index-CPCYkqCi.js} +2 -2
  67. package/apps/ui/dist/server/assets/{codex-queries-ev_mRUzr.js → codex-queries-CtgeZ7VL.js} +15 -13
  68. package/apps/ui/dist/server/assets/{codex-server-DIYFLozB.js → codex-server-CNXSJuc2.js} +104 -119
  69. package/apps/ui/dist/server/assets/concurrency-VXtYvlGh.js +18 -0
  70. package/apps/ui/dist/server/assets/{createServerRpc-Cg-Zr1rq.js → createServerRpc-CF_DEwnm.js} +1 -1
  71. package/apps/ui/dist/server/assets/{createSsrRpc-DVnVQhGx.js → createSsrRpc-C754NPki.js} +1 -1
  72. package/apps/ui/dist/server/assets/{cursor-db-B7agkAvM.js → cursor-db-cYZEU3WQ.js} +194 -7
  73. package/apps/ui/dist/server/assets/{cursor-queries-f7DjBKSD.js → cursor-queries-NCIM0Nat.js} +12 -10
  74. package/apps/ui/dist/server/assets/{cursor-recovery-9bJLs7vG.js → cursor-recovery-nq-kR62j.js} +1 -1
  75. package/apps/ui/dist/server/assets/{cursor-server-nPzojHN3.js → cursor-server-C3q7hrp-.js} +70 -41
  76. package/apps/ui/dist/server/assets/{cursor-threads._composerId-B3qcsh9C.js → cursor-threads._composerId-BPJFWfJj.js} +2 -2
  77. package/apps/ui/dist/server/assets/cursor-threads._composerId-BkMMTQ3v.js +582 -0
  78. package/apps/ui/dist/server/assets/{cursor._workspaceKey-BL_CQyHQ.js → cursor._workspaceKey-C5PdTaKB.js} +2 -2
  79. package/apps/ui/dist/server/assets/{cursor._workspaceKey-BhQeepXB.js → cursor._workspaceKey-Ckj7apKI.js} +11 -9
  80. package/apps/ui/dist/server/assets/{cursor.index-CsrdckZD.js → cursor.index-DkWT-mxw.js} +2 -2
  81. package/apps/ui/dist/server/assets/{delete-confirm-dialog-PCD7S0_M.js → delete-confirm-dialog-BcBNCxWB.js} +5 -1
  82. package/apps/ui/dist/server/assets/{empty-plugin-adapters-D3qLMPfC.js → empty-plugin-adapters-B3lHh1La.js} +1 -1
  83. package/apps/ui/dist/server/assets/{export-dialog-DaPlOGFT.js → export-dialog-BItjWgkZ.js} +53 -11
  84. package/apps/ui/dist/server/assets/{projects._project-CqqUFr4l.js → projects._project-Bpbc3C-L.js} +5 -3
  85. package/apps/ui/dist/server/assets/{projects._project-DahGqNYS.js → projects._project-CvZho6EQ.js} +42 -12
  86. package/apps/ui/dist/server/assets/{projects.index-CkTeFWNU.js → projects.index-BkLiF2FF.js} +21 -7
  87. package/apps/ui/dist/server/assets/projects.index-DesYXwfi.js +14 -0
  88. package/apps/ui/dist/server/assets/route-search-ts4W9MJ9.js +38 -0
  89. package/apps/ui/dist/server/assets/{router-K0m7dK4T.js → router-6m-ihwqA.js} +78 -62
  90. package/apps/ui/dist/server/assets/{routes-Xix3ZeSo.js → routes-Bsact1uB.js} +2 -2
  91. package/apps/ui/dist/server/assets/{routes-0x1U0IHq.js → routes-BzkJgq4W.js} +2 -2
  92. package/apps/ui/dist/server/assets/start-CI_0bSiY.js +4 -0
  93. package/apps/ui/dist/server/assets/tabs-CGA13IZM.js +502 -0
  94. package/apps/ui/dist/server/assets/{threads._threadId-BLCkGW--.js → threads._threadId-Bs0yuqO9.js} +2 -2
  95. package/apps/ui/dist/server/assets/threads._threadId-D2GYkVn6.js +639 -0
  96. package/apps/ui/dist/server/assets/ui-export-files-BHLX9bhN.js +83 -0
  97. package/apps/ui/dist/server/server.js +93 -83
  98. package/package.json +9 -9
  99. package/src/lib/antigravity-db.ts +20 -13
  100. package/src/lib/codex-analytics.ts +118 -18
  101. package/src/lib/codex-browser-db.ts +1 -25
  102. package/src/lib/codex-browser-export.ts +9 -69
  103. package/src/lib/cursor-db.ts +318 -7
  104. package/src/lib/cursor-exporter.ts +2 -2
  105. package/src/lib/ui-cache.ts +14 -1
  106. package/apps/ui/dist/client/assets/analytics-C83LcWNb.js +0 -1
  107. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-DkY8EEpK.js +0 -1
  108. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-OraHuz8j.js +0 -1
  109. package/apps/ui/dist/client/assets/antigravity-keychain-panel-DnDcq1ge.js +0 -1
  110. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CQaZscA5.js +0 -1
  111. package/apps/ui/dist/client/assets/antigravity._workspaceKey-DTs99obU.js +0 -1
  112. package/apps/ui/dist/client/assets/antigravity.index-DbJa715k.js +0 -1
  113. package/apps/ui/dist/client/assets/antigravity.index-P-rs6w5X.js +0 -1
  114. package/apps/ui/dist/client/assets/badge-wgTVp-aq.js +0 -1
  115. package/apps/ui/dist/client/assets/checkbox-BUx0Gkuf.js +0 -1
  116. package/apps/ui/dist/client/assets/cursor-threads._composerId-D0DFXt8t.js +0 -1
  117. package/apps/ui/dist/client/assets/cursor-threads._composerId-tFa6Cf6A.js +0 -1
  118. package/apps/ui/dist/client/assets/cursor._workspaceKey-Df4PfA2k.js +0 -1
  119. package/apps/ui/dist/client/assets/cursor._workspaceKey-DwrzEtnV.js +0 -1
  120. package/apps/ui/dist/client/assets/cursor.index-CNBf4ZiR.js +0 -1
  121. package/apps/ui/dist/client/assets/cursor.index-DP9Hy8X6.js +0 -2
  122. package/apps/ui/dist/client/assets/delete-confirm-dialog-CPlNsLhM.js +0 -11
  123. package/apps/ui/dist/client/assets/dist-Ds3kS91l.js +0 -1
  124. package/apps/ui/dist/client/assets/download-DLuPnxpV.js +0 -1
  125. package/apps/ui/dist/client/assets/dropdown-menu-lFk6zjoL.js +0 -1
  126. package/apps/ui/dist/client/assets/es2015-D-H_iZF6.js +0 -41
  127. package/apps/ui/dist/client/assets/export-dialog-CyqyvwiP.js +0 -1
  128. package/apps/ui/dist/client/assets/formatters-DbCKP02D.js +0 -1
  129. package/apps/ui/dist/client/assets/index-C-ZPpKJ0.js +0 -22
  130. package/apps/ui/dist/client/assets/json-panel-BsngvucB.js +0 -1
  131. package/apps/ui/dist/client/assets/metadata-section-Dtl8dNag.js +0 -1
  132. package/apps/ui/dist/client/assets/metric-card-DNzR1Rne.js +0 -1
  133. package/apps/ui/dist/client/assets/page-header-dha_O7ZL.js +0 -1
  134. package/apps/ui/dist/client/assets/projects._project-B7HBhbXv.js +0 -1
  135. package/apps/ui/dist/client/assets/projects._project-DHVvuT7A.js +0 -1
  136. package/apps/ui/dist/client/assets/projects.index-BUUtx2AZ.js +0 -1
  137. package/apps/ui/dist/client/assets/projects.index-BojAe1Y4.js +0 -3
  138. package/apps/ui/dist/client/assets/refresh-ccw-B-ASKHtx.js +0 -1
  139. package/apps/ui/dist/client/assets/reload-error-panel-DD8M1BIY.js +0 -1
  140. package/apps/ui/dist/client/assets/routes-q7bzFp35.js +0 -1
  141. package/apps/ui/dist/client/assets/scroll-text-TvHyiJ_N.js +0 -1
  142. package/apps/ui/dist/client/assets/select-BFv1HMiT.js +0 -1
  143. package/apps/ui/dist/client/assets/settings-CvegTo4F.js +0 -1
  144. package/apps/ui/dist/client/assets/styles-Ch0r3kMZ.css +0 -1
  145. package/apps/ui/dist/client/assets/text-document-panel-D7Rws41o.js +0 -1
  146. package/apps/ui/dist/client/assets/text-filter-2q5pEoZh.js +0 -2
  147. package/apps/ui/dist/client/assets/threads._threadId-LpZVUlPq.js +0 -7
  148. package/apps/ui/dist/client/assets/threads._threadId-MSmNIBkq.js +0 -1
  149. package/apps/ui/dist/client/assets/useMutation-BOPJU9e0.js +0 -1
  150. package/apps/ui/dist/client/assets/useQuery-CbxZiVGF.js +0 -1
  151. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-zvGzK-Yx.js +0 -229
  152. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-JEhx-f2R.js +0 -212
  153. package/apps/ui/dist/server/assets/cursor-threads._composerId-BK3iW-3e.js +0 -218
  154. package/apps/ui/dist/server/assets/json-panel-RYsxWFae.js +0 -16
  155. package/apps/ui/dist/server/assets/metadata-section-D6Lbc7D6.js +0 -54
  156. package/apps/ui/dist/server/assets/start-bX7bXjzw.js +0 -4
  157. package/apps/ui/dist/server/assets/text-document-panel-D8JbQWAn.js +0 -23
  158. package/apps/ui/dist/server/assets/threads._threadId-LjgsGUh-.js +0 -1052
package/package.json CHANGED
@@ -14,25 +14,25 @@
14
14
  "dependencies": {
15
15
  "@inquirer/prompts": "^8.5.2",
16
16
  "@modelcontextprotocol/sdk": "^1.29.0",
17
- "@tanstack/react-query": "5.100.14",
18
- "@tanstack/react-router": "1.170.10",
17
+ "@tanstack/react-query": "5.101.0",
18
+ "@tanstack/react-router": "1.170.15",
19
19
  "@tanstack/react-router-ssr-query": "1.167.1",
20
20
  "@tanstack/react-table": "8.21.3",
21
- "@tanstack/react-virtual": "3.13.26",
21
+ "@tanstack/react-virtual": "3.14.2",
22
22
  "class-variance-authority": "0.7.1",
23
23
  "clsx": "2.1.1",
24
24
  "iconv-lite": "^0.7.2",
25
25
  "lucide-react": "1.17.0",
26
- "radix-ui": "1.4.3",
27
- "react": "19.2.6",
28
- "react-dom": "19.2.6",
26
+ "radix-ui": "1.5.0",
27
+ "react": "19.2.7",
28
+ "react-dom": "19.2.7",
29
29
  "tailwind-merge": "3.6.0",
30
30
  "zod": "4.4.3"
31
31
  },
32
32
  "description": "Export local Codex, Claude Code, and Cursor transcripts, and browse Codex, Cursor, and Antigravity history through a local UI.",
33
33
  "devDependencies": {
34
34
  "@types/bun": "^1.3.14",
35
- "@types/node": "^25.9.1",
35
+ "@types/node": "^25.9.2",
36
36
  "typescript": "^6.0.3"
37
37
  },
38
38
  "engines": {
@@ -86,7 +86,7 @@
86
86
  "README.md",
87
87
  "AGENTS.md"
88
88
  ],
89
- "homepage": "https://github.com/ragaeeb/spiracha#readme",
89
+ "homepage": "https://github.com/ragaeeb/spiracha",
90
90
  "keywords": [
91
91
  "codex",
92
92
  "claude",
@@ -122,7 +122,7 @@
122
122
  "ui:preview": "bun run --cwd apps/ui preview"
123
123
  },
124
124
  "type": "module",
125
- "version": "1.3.3",
125
+ "version": "1.4.0",
126
126
  "workspaces": [
127
127
  "apps/*"
128
128
  ]
@@ -11,6 +11,7 @@ import {
11
11
  resolveAntigravityRoots,
12
12
  } from './antigravity-exporter-types';
13
13
  import { decryptAntigravitySafeStoragePayload } from './antigravity-keychain';
14
+ import { mapWithConcurrency } from './concurrency';
14
15
 
15
16
  type ProtoField = {
16
17
  bytes?: Uint8Array;
@@ -39,6 +40,11 @@ type ConversationFile = {
39
40
  root: string;
40
41
  };
41
42
 
43
+ const ANTIGRAVITY_ARTIFACT_READ_CONCURRENCY = 16;
44
+ const SAFE_CONVERSATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
45
+
46
+ const isSafeConversationId = (value: string) => SAFE_CONVERSATION_ID_PATTERN.test(value);
47
+
42
48
  type TranscriptFile = {
43
49
  bytes: number;
44
50
  entryCount: number;
@@ -428,25 +434,22 @@ const readArtifactsForRoot = async (root: string): Promise<Map<string, Antigravi
428
434
 
429
435
  const artifactsByConversation = new Map<string, AntigravityArtifact[]>();
430
436
  for (const entry of entries) {
431
- if (!entry.isDirectory()) {
437
+ if (!entry.isDirectory() || !isSafeConversationId(entry.name)) {
432
438
  continue;
433
439
  }
434
440
 
435
441
  const artifactDir = path.join(brainDir, entry.name);
436
442
  const files = await readdir(artifactDir, { withFileTypes: true }).catch(() => []);
437
- for (const file of files) {
438
- if (!file.isFile() || !file.name.endsWith('.md')) {
439
- continue;
443
+ const markdownFiles = files.filter((file) => file.isFile() && file.name.endsWith('.md'));
444
+ const artifacts = await mapWithConcurrency(markdownFiles, ANTIGRAVITY_ARTIFACT_READ_CONCURRENCY, (file) =>
445
+ readArtifactCandidate(root, path.join(artifactDir, file.name), file.name),
446
+ );
447
+ for (const artifact of artifacts) {
448
+ if (artifact) {
449
+ const list = artifactsByConversation.get(entry.name) ?? [];
450
+ list.push(artifact);
451
+ artifactsByConversation.set(entry.name, list);
440
452
  }
441
-
442
- const artifactPath = path.join(artifactDir, file.name);
443
- const artifact = await readArtifactCandidate(root, artifactPath, file.name);
444
- if (!artifact) {
445
- continue;
446
- }
447
- const list = artifactsByConversation.get(entry.name) ?? [];
448
- list.push(artifact);
449
- artifactsByConversation.set(entry.name, list);
450
453
  }
451
454
  }
452
455
 
@@ -811,6 +814,10 @@ const logEntryHeading = (entry: AntigravityLogEntry): string => {
811
814
  }
812
815
 
813
816
  if (source === 'MODEL') {
817
+ if (type && type !== 'PLANNER_RESPONSE') {
818
+ return `Tool: ${type}`;
819
+ }
820
+
814
821
  return 'Assistant';
815
822
  }
816
823
 
@@ -1,13 +1,37 @@
1
1
  import { listScopedThreads } from './codex-browser-db';
2
2
  import type { CodexAnalytics, DistributionItem, ModelTokenSummary } from './codex-browser-types';
3
3
  import type { ThreadRow } from './codex-exporter-types';
4
- import { getCachedParsedCodexTranscript } from './codex-thread-cache';
5
- import { getPortablePathBasename } from './shared';
6
- import { getFileFingerprint, hashCacheKeyParts, withCachedJson } from './ui-cache';
4
+ import { mapWithConcurrency } from './concurrency';
5
+ import { asObject, asString, getPortablePathBasename, readJsonlObjects } from './shared';
6
+ import { hashCacheKeyParts, hashCacheKeyPartsIterable, withCachedJson } from './ui-cache';
7
7
 
8
8
  export type CodexAnalyticsInput = {
9
9
  dbPath: string;
10
10
  project: string | null;
11
+ transcriptConcurrency?: number;
12
+ };
13
+
14
+ export type ThreadAnalyticsSummary = {
15
+ hasWebSearch: boolean;
16
+ toolNames: string[];
17
+ };
18
+
19
+ export type ComputeCodexAnalyticsOptions = {
20
+ loadThreadAnalytics?: (thread: ThreadRow) => Promise<ThreadAnalyticsSummary>;
21
+ transcriptConcurrency?: number;
22
+ };
23
+
24
+ export const DEFAULT_ANALYTICS_TRANSCRIPT_CONCURRENCY = 8;
25
+
26
+ export const resolveAnalyticsTranscriptConcurrency = (
27
+ configuredValue = process.env.SPIRACHA_ANALYTICS_TRANSCRIPT_CONCURRENCY,
28
+ ) => {
29
+ const parsed = Number(configuredValue);
30
+ if (!Number.isInteger(parsed) || parsed < 1) {
31
+ return DEFAULT_ANALYTICS_TRANSCRIPT_CONCURRENCY;
32
+ }
33
+
34
+ return parsed;
11
35
  };
12
36
 
13
37
  const roundToTwoDecimals = (value: number) => {
@@ -52,28 +76,100 @@ const buildModelsByTokens = (threads: ThreadRow[]): ModelTokenSummary[] => {
52
76
  });
53
77
  };
54
78
 
55
- const buildAnalyticsCacheKey = async (dbPath: string, threads: ThreadRow[], project: string | null) => {
56
- const dbFingerprint = await getFileFingerprint(dbPath);
57
- const rolloutFingerprints = await Promise.all(threads.map((thread) => getFileFingerprint(thread.rollout_path)));
58
- return `analytics-${hashCacheKeyParts(dbFingerprint, project ?? 'all', ...rolloutFingerprints)}`;
79
+ const timestampSignature = (thread: ThreadRow) => {
80
+ return String(thread.updated_at_ms ?? thread.updated_at * 1000);
59
81
  };
60
82
 
61
- const computeCodexAnalytics = async (threads: ThreadRow[]): Promise<CodexAnalytics> => {
83
+ const threadMetadataCacheKeyParts = (thread: ThreadRow) => [
84
+ thread.id,
85
+ thread.rollout_path,
86
+ timestampSignature(thread),
87
+ String(thread.created_at_ms ?? thread.created_at * 1000),
88
+ String(thread.tokens_used),
89
+ String(thread.archived),
90
+ String(thread.archived_at ?? ''),
91
+ thread.cwd,
92
+ thread.model ?? '',
93
+ thread.model_provider,
94
+ thread.cli_version,
95
+ thread.title,
96
+ thread.preview,
97
+ ];
98
+
99
+ export const buildCodexAnalyticsCacheKey = (dbPath: string, threads: ThreadRow[], project: string | null) => {
100
+ const parts = (function* () {
101
+ yield 'v2';
102
+ yield dbPath;
103
+ yield project ?? 'all';
104
+ yield String(threads.length);
105
+ for (const thread of threads) {
106
+ yield* threadMetadataCacheKeyParts(thread);
107
+ }
108
+ })();
109
+
110
+ return `analytics-${hashCacheKeyPartsIterable(parts)}`;
111
+ };
112
+
113
+ const buildThreadAnalyticsCacheKey = (thread: ThreadRow) => {
114
+ return `thread-analytics-${hashCacheKeyParts('v1', ...threadMetadataCacheKeyParts(thread))}`;
115
+ };
116
+
117
+ const parseThreadAnalyticsFile = async (sessionFile: string): Promise<ThreadAnalyticsSummary> => {
118
+ const toolNames: string[] = [];
119
+ let hasWebSearch = false;
120
+
121
+ for await (const parsed of readJsonlObjects(sessionFile)) {
122
+ if (parsed.type !== 'response_item') {
123
+ continue;
124
+ }
125
+
126
+ const payload = asObject(parsed.payload);
127
+ if (!payload) {
128
+ continue;
129
+ }
130
+
131
+ const payloadType = asString(payload.type);
132
+ if (payloadType === 'function_call') {
133
+ toolNames.push(asString(payload.name) ?? 'unknown');
134
+ continue;
135
+ }
136
+
137
+ if (payloadType === 'web_search_call' || payloadType === 'web_search_end') {
138
+ hasWebSearch = true;
139
+ }
140
+ }
141
+
142
+ return {
143
+ hasWebSearch,
144
+ toolNames,
145
+ };
146
+ };
147
+
148
+ const getCachedThreadAnalytics = async (thread: ThreadRow): Promise<ThreadAnalyticsSummary> => {
149
+ return withCachedJson(buildThreadAnalyticsCacheKey(thread), () => parseThreadAnalyticsFile(thread.rollout_path));
150
+ };
151
+
152
+ export const computeCodexAnalyticsFromThreads = async (
153
+ threads: ThreadRow[],
154
+ options: ComputeCodexAnalyticsOptions = {},
155
+ ): Promise<CodexAnalytics> => {
62
156
  const totalTokens = threads.reduce((sum, thread) => sum + thread.tokens_used, 0);
63
157
  const projectNames = new Set(threads.map((thread) => getPortablePathBasename(thread.cwd)).filter(Boolean));
64
158
  const toolUsage = new Map<string, number>();
65
- const transcripts = await Promise.all(threads.map((thread) => getCachedParsedCodexTranscript(thread.rollout_path)));
66
159
  let threadsWithWebSearch = 0;
67
-
68
- for (const transcript of transcripts) {
69
- if (transcript.stats.webSearchEventCount > 0) {
160
+ const loadThreadAnalytics = options.loadThreadAnalytics ?? getCachedThreadAnalytics;
161
+ const transcriptConcurrency = options.transcriptConcurrency ?? resolveAnalyticsTranscriptConcurrency();
162
+ const threadAnalytics = await mapWithConcurrency(threads, transcriptConcurrency, (thread) =>
163
+ loadThreadAnalytics(thread),
164
+ );
165
+
166
+ for (const analytics of threadAnalytics) {
167
+ if (analytics.hasWebSearch) {
70
168
  threadsWithWebSearch += 1;
71
169
  }
72
170
 
73
- for (const event of transcript.events) {
74
- if (event.kind === 'tool_call') {
75
- incrementCount(toolUsage, event.name);
76
- }
171
+ for (const toolName of analytics.toolNames) {
172
+ incrementCount(toolUsage, toolName);
77
173
  }
78
174
  }
79
175
 
@@ -94,7 +190,11 @@ const computeCodexAnalytics = async (threads: ThreadRow[]): Promise<CodexAnalyti
94
190
 
95
191
  export const getCodexAnalytics = async (input: CodexAnalyticsInput): Promise<CodexAnalytics> => {
96
192
  const threads = listScopedThreads(input.dbPath, input.project);
97
- const key = await buildAnalyticsCacheKey(input.dbPath, threads, input.project);
193
+ const key = buildCodexAnalyticsCacheKey(input.dbPath, threads, input.project);
98
194
 
99
- return withCachedJson(key, async () => computeCodexAnalytics(threads));
195
+ return withCachedJson(key, async () =>
196
+ computeCodexAnalyticsFromThreads(threads, {
197
+ transcriptConcurrency: input.transcriptConcurrency,
198
+ }),
199
+ );
100
200
  };
@@ -14,6 +14,7 @@ import type {
14
14
  import type { ThreadRelations, ThreadRow } from './codex-exporter-types';
15
15
  import { DEFAULT_CODEX_DIR, DEFAULT_DB_PATH } from './codex-exporter-types';
16
16
  import { getCachedParsedCodexTranscript, getThreadRolloutLoadState } from './codex-thread-cache';
17
+ import { mapWithConcurrency } from './concurrency';
17
18
  import { cleanInlineTitle, getPortablePathBasename } from './shared';
18
19
  import { runWithSqliteRetry } from './sqlite-retry';
19
20
  import { invalidateCacheByPrefix } from './ui-cache';
@@ -48,31 +49,6 @@ const isPromiseLike = (value: unknown): value is PromiseLike<unknown> => {
48
49
  return 'then' in value && typeof value.then === 'function';
49
50
  };
50
51
 
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
52
  const openReadonlyDb = (dbPath: string, busyTimeoutMs: number) => {
77
53
  const db = new Database(dbPath, { readonly: true });
78
54
  try {
@@ -6,6 +6,7 @@ import { convertSessionFile, writeSessionFileExport } from './codex-exporter-tra
6
6
  import type { CodexCliOptions } from './codex-exporter-types';
7
7
  import { applyPathTransforms, type PathDisplaySettings } from './path-transforms';
8
8
  import { type ExportFormat, getPortablePathBasename } from './shared';
9
+ import { getExportMimeType, sanitizeExportFileName, zipExportDirectory, zipExportFile } from './ui-export-archive';
9
10
  import { buildUiExportDownloadUrl, ensureUiExportDir } from './ui-export-files';
10
11
 
11
12
  type RenderCodexThreadDownloadInput = {
@@ -18,6 +19,7 @@ type RenderCodexThreadDownloadInput = {
18
19
  pathDisplaySettings?: Pick<PathDisplaySettings, 'convertToProjectRoot' | 'redactUsername'>;
19
20
  publicExportDir?: string;
20
21
  threadId: string;
22
+ zipArchive?: boolean;
21
23
  };
22
24
 
23
25
  type RenderCodexThreadsDownloadInput = Omit<RenderCodexThreadDownloadInput, 'threadId'> & {
@@ -40,14 +42,6 @@ export type CodexThreadDownload =
40
42
 
41
43
  const LARGE_BROWSER_EXPORT_THRESHOLD_BYTES = 128 * 1024 * 1024;
42
44
 
43
- const toSanitizedFileName = (value: string) => {
44
- return value
45
- .replace(/[<>:"/\\|?*\u0000-\u001f]/gu, ' ')
46
- .replace(/\.\.+/gu, ' ')
47
- .replace(/\s+/gu, ' ')
48
- .trim();
49
- };
50
-
51
45
  const formatReadableExportDate = (value: number) => {
52
46
  const date = new Date(value);
53
47
  const year = date.getUTCFullYear();
@@ -59,7 +53,7 @@ const formatReadableExportDate = (value: number) => {
59
53
  };
60
54
 
61
55
  const buildExportBaseName = (thread: ReturnType<typeof getThreadBrowseData>['thread']) => {
62
- const projectName = toSanitizedFileName(getPortablePathBasename(thread.cwd) || 'thread') || 'thread';
56
+ const projectName = sanitizeExportFileName(getPortablePathBasename(thread.cwd) || 'thread') || 'thread';
63
57
  const timestamp = thread.updated_at_ms ?? thread.updated_at * 1000;
64
58
  return `${projectName}-${formatReadableExportDate(timestamp)}-${thread.id.slice(0, 8)}`;
65
59
  };
@@ -70,7 +64,7 @@ const buildBatchExportBaseName = (threads: Array<ReturnType<typeof getThreadBrow
70
64
  throw new Error('No threads selected for export');
71
65
  }
72
66
 
73
- const projectName = toSanitizedFileName(getPortablePathBasename(firstThread.cwd) || 'threads') || 'threads';
67
+ const projectName = sanitizeExportFileName(getPortablePathBasename(firstThread.cwd) || 'threads') || 'threads';
74
68
  const latestTimestamp = Math.max(...threads.map((thread) => thread.updated_at_ms ?? thread.updated_at * 1000));
75
69
  return `${projectName}-${formatReadableExportDate(latestTimestamp)}-threads-${threads.length}`;
76
70
  };
@@ -111,10 +105,6 @@ const toDownloadOptions = (input: RenderCodexThreadDownloadInput): CodexCliOptio
111
105
  };
112
106
  };
113
107
 
114
- const getMimeType = (outputFormat: ExportFormat) => {
115
- return outputFormat === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
116
- };
117
-
118
108
  const resolvePublicExportDir = async (publicExportDir?: string) => {
119
109
  if (publicExportDir) {
120
110
  await ensureDirectory(publicExportDir);
@@ -146,7 +136,6 @@ const logExportEvent = (level: 'error' | 'info' | 'warn', event: string, details
146
136
 
147
137
  const logRolloutChangeIfDetected = (
148
138
  threadId: string,
149
- rolloutPath: string,
150
139
  beforeSnapshot: RolloutSnapshot,
151
140
  afterSnapshot: RolloutSnapshot,
152
141
  ) => {
@@ -159,7 +148,6 @@ const logRolloutChangeIfDetected = (
159
148
  afterSizeBytes: afterSnapshot.sizeBytes,
160
149
  beforeMtimeMs: beforeSnapshot.mtimeMs,
161
150
  beforeSizeBytes: beforeSnapshot.sizeBytes,
162
- rolloutPath,
163
151
  threadId,
164
152
  });
165
153
  };
@@ -175,39 +163,6 @@ const cleanupExportWorkspace = async (workspacePath: string) => {
175
163
  }
176
164
  };
177
165
 
178
- const zipExportFile = async (sourcePath: string, zipPath: string) => {
179
- const proc = Bun.spawn(['zip', '-9', '-j', zipPath, sourcePath], {
180
- stderr: 'pipe',
181
- stdout: 'pipe',
182
- });
183
- const [stdoutText, stderrText, exitCode] = await Promise.all([
184
- new Response(proc.stdout).text(),
185
- new Response(proc.stderr).text(),
186
- proc.exited,
187
- ]);
188
-
189
- if (exitCode !== 0) {
190
- throw new Error(`zip failed (${exitCode}): ${(stderrText || stdoutText).trim()}`);
191
- }
192
- };
193
-
194
- const zipExportDirectory = async (sourceDirectory: string, zipPath: string) => {
195
- const proc = Bun.spawn(['zip', '-9', '-r', zipPath, '.'], {
196
- cwd: sourceDirectory,
197
- stderr: 'pipe',
198
- stdout: 'pipe',
199
- });
200
- const [stdoutText, stderrText, exitCode] = await Promise.all([
201
- new Response(proc.stdout).text(),
202
- new Response(proc.stderr).text(),
203
- proc.exited,
204
- ]);
205
-
206
- if (exitCode !== 0) {
207
- throw new Error(`zip failed (${exitCode}): ${(stderrText || stdoutText).trim()}`);
208
- }
209
- };
210
-
211
166
  export const renderCodexThreadDownload = async (
212
167
  input: RenderCodexThreadDownloadInput,
213
168
  ): Promise<CodexThreadDownload> => {
@@ -216,7 +171,7 @@ export const renderCodexThreadDownload = async (
216
171
  const extension = input.outputFormat === 'md' ? 'md' : 'txt';
217
172
  const fileBaseName = buildExportBaseName(browseData.thread);
218
173
  const fileName = `${fileBaseName}.${extension}`;
219
- const mimeType = getMimeType(input.outputFormat);
174
+ const mimeType = getExportMimeType(input.outputFormat);
220
175
  const transform = (text: string) =>
221
176
  input.pathDisplaySettings
222
177
  ? applyPathTransforms(text, {
@@ -228,13 +183,13 @@ export const renderCodexThreadDownload = async (
228
183
 
229
184
  logExportEvent('info', 'single_start', {
230
185
  fileName,
231
- rolloutPath: browseData.thread.rollout_path,
232
186
  sizeBytes: rolloutSnapshotBefore.sizeBytes,
233
187
  threadId: input.threadId,
234
188
  });
235
189
 
236
190
  try {
237
191
  if (
192
+ input.zipArchive ||
238
193
  rolloutSnapshotBefore.sizeBytes > (input.largeExportThresholdBytes ?? LARGE_BROWSER_EXPORT_THRESHOLD_BYTES)
239
194
  ) {
240
195
  const exportBaseName = fileBaseName;
@@ -266,12 +221,7 @@ export const renderCodexThreadDownload = async (
266
221
  }
267
222
 
268
223
  const rolloutSnapshotAfter = await getRolloutSnapshot(browseData.thread.rollout_path);
269
- logRolloutChangeIfDetected(
270
- input.threadId,
271
- browseData.thread.rollout_path,
272
- rolloutSnapshotBefore,
273
- rolloutSnapshotAfter,
274
- );
224
+ logRolloutChangeIfDetected(input.threadId, rolloutSnapshotBefore, rolloutSnapshotAfter);
275
225
 
276
226
  const zipStat = await Bun.file(zipPath).stat();
277
227
  logExportEvent('info', 'single_zip_ready', {
@@ -307,12 +257,7 @@ export const renderCodexThreadDownload = async (
307
257
  }
308
258
 
309
259
  const rolloutSnapshotAfter = await getRolloutSnapshot(browseData.thread.rollout_path);
310
- logRolloutChangeIfDetected(
311
- input.threadId,
312
- browseData.thread.rollout_path,
313
- rolloutSnapshotBefore,
314
- rolloutSnapshotAfter,
315
- );
260
+ logRolloutChangeIfDetected(input.threadId, rolloutSnapshotBefore, rolloutSnapshotAfter);
316
261
  logExportEvent('info', 'single_inline_ready', {
317
262
  durationMs: Date.now() - startedAt,
318
263
  fileName,
@@ -412,12 +357,7 @@ export const renderCodexThreadsDownload = async (
412
357
  }
413
358
 
414
359
  const rolloutSnapshotAfter = await getRolloutSnapshot(entry.thread.rollout_path);
415
- logRolloutChangeIfDetected(
416
- entry.thread.id,
417
- entry.thread.rollout_path,
418
- rolloutSnapshotBefore,
419
- rolloutSnapshotAfter,
420
- );
360
+ logRolloutChangeIfDetected(entry.thread.id, rolloutSnapshotBefore, rolloutSnapshotAfter);
421
361
  }
422
362
 
423
363
  await zipExportDirectory(bundleDirectory, zipPath);