spiracha 2.0.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 (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -1,27 +1,152 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
2
+ import { chmod, lstat, mkdir, readdir, rename, rm, stat, utimes } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
6
  const CACHE_DIR = path.join(os.tmpdir(), 'spiracha-ui-cache');
7
7
  const CACHE_ENVELOPE_VERSION = 1;
8
+ const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
9
+ const DEFAULT_CACHE_MAX_BYTES = 256 * 1024 * 1024;
10
+ const CACHE_PURGE_INTERVAL_MS = 60 * 1000;
11
+ const CACHE_KEY_PREFIX_MAX_LENGTH = 80;
8
12
 
9
13
  type CacheEnvelope<T> = {
10
14
  value: T;
11
15
  version: number;
12
16
  };
13
17
 
14
- const ensureCacheDir = async () => {
15
- await mkdir(CACHE_DIR, { recursive: true });
18
+ type CacheReadResult<T> = { hit: true; value: T } | { hit: false };
19
+
20
+ const inFlightCacheLoads = new Map<string, Promise<unknown>>();
21
+ const activeCachePathCounts = new Map<string, number>();
22
+ const pendingCachePathRemovals = new Set<string>();
23
+ let cacheDirectoryInitialized = false;
24
+ let cacheInvalidationGeneration = 0;
25
+ let lastCachePurgeAtMs = 0;
26
+
27
+ const removeInactiveCachePath = async (filePath: string): Promise<void> => {
28
+ if (activeCachePathCounts.has(filePath)) {
29
+ pendingCachePathRemovals.add(filePath);
30
+ return;
31
+ }
32
+ await rm(filePath, { force: true });
16
33
  };
17
34
 
18
- const toCachePath = (key: string) => {
19
- const safeKey = key.replace(/[^a-zA-Z0-9._-]/gu, '_');
20
- return path.join(CACHE_DIR, `${safeKey}-${hashCacheKeyParts(key)}.json`);
35
+ const beginActiveCachePath = (filePath: string): void => {
36
+ activeCachePathCounts.set(filePath, (activeCachePathCounts.get(filePath) ?? 0) + 1);
37
+ };
38
+
39
+ const finishActiveCachePath = async (filePath: string): Promise<void> => {
40
+ const remainingCount = (activeCachePathCounts.get(filePath) ?? 1) - 1;
41
+ if (remainingCount > 0) {
42
+ activeCachePathCounts.set(filePath, remainingCount);
43
+ return;
44
+ }
45
+ activeCachePathCounts.delete(filePath);
46
+ if (pendingCachePathRemovals.delete(filePath)) {
47
+ await rm(filePath, { force: true });
48
+ }
49
+ };
50
+
51
+ export const pruneUiCacheEntries = async (
52
+ cacheDir: string = CACHE_DIR,
53
+ maxAgeMs: number = DEFAULT_CACHE_MAX_AGE_MS,
54
+ maxBytes: number = DEFAULT_CACHE_MAX_BYTES,
55
+ ) => {
56
+ const cutoff = Date.now() - maxAgeMs;
57
+ const entries = await readdir(cacheDir, { withFileTypes: true }).catch((error: unknown) => {
58
+ if ((error as { code?: unknown }).code === 'ENOENT') {
59
+ return [];
60
+ }
61
+ throw error;
62
+ });
63
+ const cacheFiles = (
64
+ await Promise.all(
65
+ entries
66
+ .filter((entry) => entry.isFile())
67
+ .map(async (entry) => {
68
+ const filePath = path.join(cacheDir, entry.name);
69
+ try {
70
+ const metadata = await stat(filePath);
71
+ return { filePath, mtimeMs: metadata.mtimeMs, name: entry.name, size: metadata.size };
72
+ } catch (error) {
73
+ if ((error as { code?: unknown }).code === 'ENOENT') {
74
+ return null;
75
+ }
76
+ throw error;
77
+ }
78
+ }),
79
+ )
80
+ ).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
81
+ const staleFiles = cacheFiles.filter((entry) => entry.name.endsWith('.json') && entry.mtimeMs < cutoff);
82
+ await Promise.all(staleFiles.map((entry) => removeInactiveCachePath(entry.filePath)));
83
+
84
+ const retainedCacheFiles = cacheFiles.filter((entry) => entry.mtimeMs >= cutoff && entry.name.endsWith('.json'));
85
+ let retainedBytes = retainedCacheFiles.reduce((total, entry) => total + entry.size, 0);
86
+ if (retainedBytes <= maxBytes) {
87
+ return;
88
+ }
89
+
90
+ const retainedOldestFirst = retainedCacheFiles.sort(
91
+ (left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name),
92
+ );
93
+ const oversizedFiles = [];
94
+ for (const entry of retainedOldestFirst) {
95
+ if (retainedBytes <= maxBytes) {
96
+ break;
97
+ }
98
+ oversizedFiles.push(entry.filePath);
99
+ retainedBytes -= entry.size;
100
+ }
101
+ await Promise.all(oversizedFiles.map(removeInactiveCachePath));
102
+ };
103
+
104
+ const assertSafeCacheDirectory = async () => {
105
+ const metadata = await lstat(CACHE_DIR);
106
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
107
+ throw new Error(`Unsafe Spiracha cache directory: ${CACHE_DIR}`);
108
+ }
109
+ return metadata;
110
+ };
111
+
112
+ const refreshCacheDirectoryState = async () => {
113
+ try {
114
+ await assertSafeCacheDirectory();
115
+ } catch (error) {
116
+ if ((error as { code?: unknown }).code !== 'ENOENT') {
117
+ throw error;
118
+ }
119
+ cacheDirectoryInitialized = false;
120
+ }
21
121
  };
22
122
 
23
- export const hashCacheKeyParts = (...parts: string[]) => {
24
- return createHash('sha1').update(parts.join('|')).digest('hex');
123
+ const ensureCacheDir = async () => {
124
+ if (cacheDirectoryInitialized) {
125
+ await refreshCacheDirectoryState();
126
+ }
127
+
128
+ if (!cacheDirectoryInitialized) {
129
+ await mkdir(CACHE_DIR, { mode: 0o700, recursive: true });
130
+ const metadata = await assertSafeCacheDirectory();
131
+ if ((metadata.mode & 0o777) !== 0o700) {
132
+ await chmod(CACHE_DIR, 0o700);
133
+ }
134
+ cacheDirectoryInitialized = true;
135
+ }
136
+ const now = Date.now();
137
+ if (now - lastCachePurgeAtMs >= CACHE_PURGE_INTERVAL_MS) {
138
+ lastCachePurgeAtMs = now;
139
+ void pruneUiCacheEntries(CACHE_DIR).catch((error) => {
140
+ console.warn('[spiracha:ui-cache] cache pruning failed', {
141
+ error: error instanceof Error ? error.message : String(error),
142
+ });
143
+ });
144
+ }
145
+ };
146
+
147
+ const toCachePath = (key: string) => {
148
+ const safeKey = key.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(0, CACHE_KEY_PREFIX_MAX_LENGTH);
149
+ return path.join(CACHE_DIR, `${safeKey}-${hashCacheKeyPartsIterable([key])}.json`);
25
150
  };
26
151
 
27
152
  export const hashCacheKeyPartsIterable = (parts: Iterable<string>) => {
@@ -41,20 +166,23 @@ export const getFileFingerprint = async (filePath: string) => {
41
166
  return `${filePath}:${metadata.size}:${metadata.mtimeMs}`;
42
167
  };
43
168
 
44
- export const getCachedJson = async <T>(key: string): Promise<T | null> => {
169
+ const readCachedJson = async <T>(key: string): Promise<CacheReadResult<T>> => {
45
170
  await ensureCacheDir();
46
171
  const filePath = toCachePath(key);
47
172
  const file = Bun.file(filePath);
48
173
  if (!(await file.exists())) {
49
- return null;
174
+ return { hit: false };
50
175
  }
51
176
 
52
177
  let parsed: CacheEnvelope<T> | T;
178
+ beginActiveCachePath(filePath);
53
179
  try {
54
180
  parsed = (await file.json()) as CacheEnvelope<T> | T;
55
181
  } catch {
56
182
  await rm(filePath, { force: true });
57
- return null;
183
+ return { hit: false };
184
+ } finally {
185
+ await finishActiveCachePath(filePath);
58
186
  }
59
187
 
60
188
  if (
@@ -64,11 +192,18 @@ export const getCachedJson = async <T>(key: string): Promise<T | null> => {
64
192
  (parsed as CacheEnvelope<T>).version === CACHE_ENVELOPE_VERSION &&
65
193
  'value' in parsed
66
194
  ) {
67
- return (parsed as CacheEnvelope<T>).value;
195
+ const now = new Date();
196
+ await utimes(filePath, now, now).catch(() => undefined);
197
+ return { hit: true, value: (parsed as CacheEnvelope<T>).value };
68
198
  }
69
199
 
70
200
  await rm(filePath, { force: true });
71
- return null;
201
+ return { hit: false };
202
+ };
203
+
204
+ export const getCachedJson = async <T>(key: string): Promise<T | null> => {
205
+ const cached = await readCachedJson<T>(key);
206
+ return cached.hit ? cached.value : null;
72
207
  };
73
208
 
74
209
  export const setCachedJson = async <T>(key: string, value: T) => {
@@ -80,30 +215,56 @@ export const setCachedJson = async <T>(key: string, value: T) => {
80
215
  version: CACHE_ENVELOPE_VERSION,
81
216
  };
82
217
 
83
- await Bun.write(tempPath, JSON.stringify(envelope));
84
- await rename(tempPath, filePath);
218
+ beginActiveCachePath(filePath);
219
+ beginActiveCachePath(tempPath);
220
+ try {
221
+ await Bun.write(tempPath, JSON.stringify(envelope));
222
+ await rename(tempPath, filePath);
223
+ } finally {
224
+ await rm(tempPath, { force: true });
225
+ await finishActiveCachePath(tempPath);
226
+ await finishActiveCachePath(filePath);
227
+ }
85
228
  };
86
229
 
87
230
  export const withCachedJson = async <T>(key: string, loader: () => Promise<T>): Promise<T> => {
88
- const filePath = toCachePath(key);
89
- const existedBeforeRead = await Bun.file(filePath).exists();
90
- const cached = await getCachedJson<T>(key);
91
- if (cached !== null || (existedBeforeRead && (await Bun.file(filePath).exists()))) {
92
- return cached as T;
231
+ const inFlight = inFlightCacheLoads.get(key);
232
+ if (inFlight) {
233
+ return (await inFlight) as T;
93
234
  }
94
235
 
95
- const value = await loader();
96
- await setCachedJson(key, value);
97
- return value;
236
+ const load = (async () => {
237
+ const generation = cacheInvalidationGeneration;
238
+ const cached = await readCachedJson<T>(key);
239
+ if (cached.hit) {
240
+ return cached.value;
241
+ }
242
+
243
+ const value = await loader();
244
+ if (generation === cacheInvalidationGeneration) {
245
+ await setCachedJson(key, value);
246
+ }
247
+ return value;
248
+ })();
249
+ inFlightCacheLoads.set(key, load);
250
+
251
+ try {
252
+ return await load;
253
+ } finally {
254
+ if (inFlightCacheLoads.get(key) === load) {
255
+ inFlightCacheLoads.delete(key);
256
+ }
257
+ }
98
258
  };
99
259
 
100
260
  export const invalidateCacheByPrefix = async (...prefixes: string[]) => {
261
+ cacheInvalidationGeneration += 1;
101
262
  await ensureCacheDir();
102
263
  const entries = await readdir(CACHE_DIR);
103
264
 
104
265
  await Promise.all(
105
266
  entries
106
267
  .filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)))
107
- .map((entry) => rm(path.join(CACHE_DIR, entry), { force: true })),
268
+ .map((entry) => removeInactiveCachePath(path.join(CACHE_DIR, entry))),
108
269
  );
109
270
  };
@@ -1,5 +1,15 @@
1
+ import { getPortablePathBasename } from './portable-path';
1
2
  import type { ExportFormat } from './shared';
2
3
 
4
+ type BatchExportNameEntry = {
5
+ cwd: string | null;
6
+ updatedAtMs: number | null;
7
+ };
8
+
9
+ type ConversationExportNameEntry = BatchExportNameEntry & {
10
+ id: string;
11
+ };
12
+
3
13
  export const sanitizeExportFileName = (value: string) => {
4
14
  return value
5
15
  .replace(/[<>:"/\\|?*\u0000-\u001f]/gu, ' ')
@@ -13,49 +23,58 @@ export const getExportMimeType = (outputFormat: ExportFormat) => {
13
23
  };
14
24
 
15
25
  export const resolveUniqueExportFileBaseName = (baseName: string, usedCounts: Map<string, number>) => {
16
- const count = (usedCounts.get(baseName) ?? 0) + 1;
17
- usedCounts.set(baseName, count);
18
- return count === 1 ? baseName : `${baseName}-${count}`;
19
- };
20
-
21
- const readPipeText = async (pipe: ReadableStream<Uint8Array> | number | undefined) => {
22
- return pipe && typeof pipe !== 'number' ? new Response(pipe).text() : '';
23
- };
24
-
25
- const runZip = async (args: string[], options: { cwd?: string } = {}) => {
26
- let proc: ReturnType<typeof Bun.spawn>;
27
- try {
28
- proc = Bun.spawn(['zip', '-9', ...args], {
29
- cwd: options.cwd,
30
- stderr: 'pipe',
31
- stdout: 'pipe',
32
- });
33
- } catch (error) {
34
- throw new Error(
35
- `zip command failed to start. Install the "zip" executable or choose a non-zip export. ${error instanceof Error ? error.message : String(error)}`,
36
- );
37
- }
26
+ const normalizeKey = (value: string) => value.normalize('NFC').toLowerCase();
27
+ const baseKey = normalizeKey(baseName);
28
+ let count = (usedCounts.get(baseKey) ?? 0) + 1;
29
+ let candidate = count === 1 ? baseName : `${baseName}-${count}`;
38
30
 
39
- const [stdoutText, stderrText, exitCode] = await Promise.all([
40
- readPipeText(proc.stdout),
41
- readPipeText(proc.stderr),
42
- proc.exited,
43
- ]);
44
-
45
- if (exitCode !== 0) {
46
- const output = (stderrText || stdoutText).trim();
47
- throw new Error(
48
- output
49
- ? `zip command failed (${exitCode}): ${output}`
50
- : `zip command failed (${exitCode}). Install the "zip" executable or choose a non-zip export.`,
51
- );
31
+ while (usedCounts.has(normalizeKey(candidate))) {
32
+ count += 1;
33
+ candidate = `${baseName}-${count}`;
52
34
  }
35
+
36
+ usedCounts.set(baseKey, count);
37
+ usedCounts.set(normalizeKey(candidate), Math.max(usedCounts.get(normalizeKey(candidate)) ?? 0, 1));
38
+ return candidate;
53
39
  };
54
40
 
55
- export const zipExportFile = async (sourcePath: string, zipPath: string) => {
56
- await runZip(['-j', zipPath, sourcePath]);
41
+ const formatBatchExportDate = (value: number) => {
42
+ const date = new Date(value);
43
+ const year = date.getUTCFullYear();
44
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
45
+ const day = String(date.getUTCDate()).padStart(2, '0');
46
+ const hours = String(date.getUTCHours()).padStart(2, '0');
47
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
48
+ return `${year}-${month}-${day}-${hours}${minutes}`;
57
49
  };
58
50
 
59
- export const zipExportDirectory = async (sourceDirectory: string, zipPath: string) => {
60
- await runZip(['-r', zipPath, '.'], { cwd: sourceDirectory });
51
+ const resolveExportProjectName = (cwd: string | null, fallbackProjectName: string) => {
52
+ return sanitizeExportFileName(getPortablePathBasename(cwd ?? '') || fallbackProjectName) || 'threads';
53
+ };
54
+
55
+ export const buildConversationExportBaseName = (
56
+ { cwd, id, updatedAtMs }: ConversationExportNameEntry,
57
+ fallbackProjectName: string,
58
+ ) => {
59
+ const projectName = resolveExportProjectName(cwd, fallbackProjectName);
60
+ const shortId = sanitizeExportFileName(id).slice(0, 8) || 'conversation';
61
+ return Number.isFinite(updatedAtMs) && (updatedAtMs ?? 0) > 0
62
+ ? `${projectName}-${formatBatchExportDate(updatedAtMs!)}-${shortId}`
63
+ : `${projectName}-${shortId}`;
64
+ };
65
+
66
+ export const buildBatchExportBaseName = (entries: BatchExportNameEntry[], fallbackProjectName: string) => {
67
+ if (entries.length === 0) {
68
+ throw new Error('No conversations selected for export');
69
+ }
70
+
71
+ const firstCwd = entries.find((entry) => entry.cwd?.trim())?.cwd ?? null;
72
+ const projectName = resolveExportProjectName(firstCwd, fallbackProjectName);
73
+ const latestUpdatedAtMs = Math.max(
74
+ ...entries.map((entry) => (Number.isFinite(entry.updatedAtMs) ? (entry.updatedAtMs ?? 0) : 0)),
75
+ );
76
+
77
+ return latestUpdatedAtMs > 0
78
+ ? `${projectName}-${formatBatchExportDate(latestUpdatedAtMs)}-threads-${entries.length}`
79
+ : `${projectName}-threads-${entries.length}`;
61
80
  };
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, rm, stat } from 'node:fs/promises';
1
+ import { chmod, mkdir, readdir, rm, stat } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
@@ -7,6 +7,8 @@ export const UI_EXPORT_URL_PREFIX = '/__exports/';
7
7
 
8
8
  const DEFAULT_UI_EXPORT_DIR = path.join(os.tmpdir(), 'spiracha-ui-exports');
9
9
  const DEFAULT_EXPORT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
10
+ const DEFAULT_EXPORT_MAX_BYTES = 1024 * 1024 * 1024;
11
+ const MAX_EXPORT_FILE_NAME_BYTES = 200;
10
12
 
11
13
  const decodeExportFileName = (value: string) => {
12
14
  try {
@@ -17,7 +19,15 @@ const decodeExportFileName = (value: string) => {
17
19
  };
18
20
 
19
21
  const isSafeExportFileName = (value: string) => {
20
- return value.length > 0 && value === path.basename(value) && !/[\\/]/u.test(value) && !value.includes('\0');
22
+ return (
23
+ value.length > 0 &&
24
+ Buffer.byteLength(value) <= MAX_EXPORT_FILE_NAME_BYTES &&
25
+ value !== '.' &&
26
+ value !== '..' &&
27
+ value === path.basename(value) &&
28
+ !/[\\/]/u.test(value) &&
29
+ !value.includes('\0')
30
+ );
21
31
  };
22
32
 
23
33
  export const getUiExportDir = () => {
@@ -26,7 +36,8 @@ export const getUiExportDir = () => {
26
36
 
27
37
  export const ensureUiExportDir = async () => {
28
38
  const exportDir = getUiExportDir();
29
- await mkdir(exportDir, { recursive: true });
39
+ await mkdir(exportDir, { mode: 0o700, recursive: true });
40
+ await chmod(exportDir, 0o700);
30
41
  await purgeStaleUiExports(exportDir);
31
42
  return exportDir;
32
43
  };
@@ -40,26 +51,61 @@ export const buildUiExportContentDisposition = (filePath: string) => {
40
51
  return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`;
41
52
  };
42
53
 
54
+ export const purgeStaleUiExportFile = async (filePath: string, cutoff: number) => {
55
+ let metadata: Awaited<ReturnType<typeof stat>>;
56
+ try {
57
+ metadata = await stat(filePath);
58
+ } catch (error) {
59
+ if ((error as { code?: unknown }).code === 'ENOENT') {
60
+ return;
61
+ }
62
+ throw error;
63
+ }
64
+ if (metadata.mtimeMs < cutoff) {
65
+ await rm(filePath, { force: true });
66
+ }
67
+ };
68
+
43
69
  export const purgeStaleUiExports = async (
44
70
  exportDir: string = getUiExportDir(),
45
71
  maxAgeMs: number = DEFAULT_EXPORT_MAX_AGE_MS,
72
+ maxBytes: number = DEFAULT_EXPORT_MAX_BYTES,
46
73
  ) => {
47
74
  const entries = await readdir(exportDir, { withFileTypes: true });
48
75
  const cutoff = Date.now() - maxAgeMs;
76
+ const files = (
77
+ await Promise.all(
78
+ entries
79
+ .filter((entry) => entry.isFile())
80
+ .map(async (entry) => {
81
+ const filePath = path.join(exportDir, entry.name);
82
+ try {
83
+ const metadata = await stat(filePath);
84
+ return { filePath, mtimeMs: metadata.mtimeMs, name: entry.name, size: metadata.size };
85
+ } catch (error) {
86
+ if ((error as { code?: unknown }).code === 'ENOENT') {
87
+ return null;
88
+ }
89
+ throw error;
90
+ }
91
+ }),
92
+ )
93
+ ).filter((file): file is NonNullable<typeof file> => file !== null);
94
+ const staleFiles = files.filter((file) => file.mtimeMs < cutoff);
95
+ await Promise.all(staleFiles.map((file) => rm(file.filePath, { force: true })));
49
96
 
50
- await Promise.all(
51
- entries
52
- .filter((entry) => entry.isFile())
53
- .map(async (entry) => {
54
- const filePath = path.join(exportDir, entry.name);
55
- const metadata = await stat(filePath);
56
- if (metadata.mtimeMs >= cutoff) {
57
- return;
58
- }
59
-
60
- await rm(filePath, { force: true });
61
- }),
97
+ const retainedFiles = files.filter((file) => file.mtimeMs >= cutoff);
98
+ let retainedBytes = retainedFiles.reduce((total, file) => total + file.size, 0);
99
+ const oldestFirst = retainedFiles.sort(
100
+ (left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name),
62
101
  );
102
+ for (const file of oldestFirst) {
103
+ if (retainedBytes <= maxBytes) {
104
+ break;
105
+ }
106
+ await rm(file.filePath, { force: true });
107
+ retainedBytes -= file.size;
108
+ }
63
109
  };
64
110
 
65
111
  export const resolveUiExportFilePathFromRequestPath = (pathname: string) => {
@@ -75,3 +121,17 @@ export const resolveUiExportFilePathFromRequestPath = (pathname: string) => {
75
121
 
76
122
  return path.join(getUiExportDir(), fileName);
77
123
  };
124
+
125
+ export const resolveReadableUiExportFileFromRequestPath = async (pathname: string) => {
126
+ const filePath = resolveUiExportFilePathFromRequestPath(pathname);
127
+ if (!filePath) {
128
+ return null;
129
+ }
130
+
131
+ try {
132
+ const metadata = await stat(filePath);
133
+ return metadata.isFile() ? filePath : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
@@ -0,0 +1,40 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { type Zippable, zipSync } from 'fflate';
4
+
5
+ const readZipFile = async (filePath: string) => {
6
+ return new Uint8Array(await Bun.file(filePath).arrayBuffer());
7
+ };
8
+
9
+ const createZip = async (files: Zippable) => zipSync(files, { level: 3 });
10
+
11
+ const readZipDirectory = async (
12
+ rootDirectory: string,
13
+ currentDirectory = rootDirectory,
14
+ files: Zippable = {},
15
+ ): Promise<Zippable> => {
16
+ const entries = await readdir(currentDirectory, { withFileTypes: true });
17
+ await Promise.all(
18
+ entries.map(async (entry) => {
19
+ const entryPath = path.join(currentDirectory, entry.name);
20
+ if (entry.isDirectory()) {
21
+ await readZipDirectory(rootDirectory, entryPath, files);
22
+ return;
23
+ }
24
+
25
+ if (entry.isFile()) {
26
+ const archivePath = path.relative(rootDirectory, entryPath).split(path.sep).join('/');
27
+ files[archivePath] = await readZipFile(entryPath);
28
+ }
29
+ }),
30
+ );
31
+ return files;
32
+ };
33
+
34
+ export const zipExportFile = async (sourcePath: string, zipPath: string) => {
35
+ await Bun.write(zipPath, await createZip({ [path.basename(sourcePath)]: await readZipFile(sourcePath) }));
36
+ };
37
+
38
+ export const zipExportDirectory = async (sourceDirectory: string, zipPath: string) => {
39
+ await Bun.write(zipPath, await createZip(await readZipDirectory(sourceDirectory)));
40
+ };
@@ -1,65 +0,0 @@
1
- {
2
- "dependencies": {
3
- "@tailwindcss/vite": "4.3.1",
4
- "@tanstack/match-sorter-utils": "8.19.4",
5
- "@tanstack/react-devtools": "0.10.5",
6
- "@tanstack/react-query": "5.101.0",
7
- "@tanstack/react-query-devtools": "5.101.0",
8
- "@tanstack/react-router": "1.170.15",
9
- "@tanstack/react-router-devtools": "1.167.0",
10
- "@tanstack/react-router-ssr-query": "1.167.1",
11
- "@tanstack/react-start": "1.168.25",
12
- "@tanstack/react-table": "8.21.3",
13
- "@tanstack/react-virtual": "3.14.2",
14
- "@tanstack/router-plugin": "1.168.18",
15
- "class-variance-authority": "0.7.1",
16
- "clsx": "2.1.1",
17
- "lucide-react": "1.18.0",
18
- "radix-ui": "1.5.0",
19
- "react": "19.2.7",
20
- "react-dom": "19.2.7",
21
- "tailwind-merge": "3.6.0",
22
- "tailwindcss": "4.3.1",
23
- "tw-animate-css": "1.4.0",
24
- "zod": "4.4.3"
25
- },
26
- "devDependencies": {
27
- "@biomejs/biome": "2.5.0",
28
- "@tailwindcss/typography": "0.5.20",
29
- "@tanstack/devtools-vite": "0.7.0",
30
- "@testing-library/dom": "10.4.1",
31
- "@testing-library/react": "16.3.2",
32
- "@types/node": "25.9.3",
33
- "@types/react": "19.2.17",
34
- "@types/react-dom": "19.2.3",
35
- "@vitejs/plugin-react": "6.0.2",
36
- "@vitest/coverage-v8": "4.1.8",
37
- "jsdom": "29.1.1",
38
- "typescript": "6.0.3",
39
- "vite": "8.0.16",
40
- "vitest": "4.1.8"
41
- },
42
- "imports": {
43
- "#/*": "./src/*"
44
- },
45
- "name": "ui",
46
- "pnpm": {
47
- "onlyBuiltDependencies": [
48
- "esbuild",
49
- "lightningcss"
50
- ]
51
- },
52
- "private": true,
53
- "scripts": {
54
- "build": "bun --bun vite build",
55
- "check": "biome check",
56
- "coverage": "vitest run --coverage",
57
- "dev": "bun --bun vite dev --host 127.0.0.1 --port 3000",
58
- "format": "biome format",
59
- "lint": "biome lint",
60
- "preview": "bun --bun vite preview",
61
- "test": "vitest run",
62
- "typecheck": "tsc --noEmit"
63
- },
64
- "type": "module"
65
- }