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.
- package/AGENTS.md +27 -12
- package/README.md +42 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +36 -11
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +46 -3
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +23 -5
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +79 -74
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +13 -47
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +209 -11
- package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +185 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +16 -0
- package/apps/ui/src/lib/codex-server.ts +134 -52
- package/apps/ui/src/lib/cursor-server.ts +68 -53
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +168 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +97 -7
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +96 -7
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
- package/apps/ui/src/routeTree.gen.ts +341 -234
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
- package/apps/ui/src/routes/opencode.index.tsx +6 -2
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +37 -27
- package/src/client.ts +183 -5
- package/src/lib/antigravity-db.ts +424 -94
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +534 -42
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +18 -27
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +106 -23
- package/src/lib/codex-thread-parser.ts +149 -55
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +87 -79
- package/src/lib/concurrency.ts +56 -3
- package/src/lib/conversation-api.ts +410 -27
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
- package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
- package/src/lib/conversation-data/codex-adapter.ts +108 -73
- package/src/lib/conversation-data/cursor-adapter.ts +77 -29
- package/src/lib/conversation-data/grok-adapter.ts +223 -0
- package/src/lib/conversation-data/index.ts +105 -11
- package/src/lib/conversation-data/kiro-adapter.ts +59 -12
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +67 -17
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +67 -37
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +75 -0
- package/src/lib/cursor-db.ts +68 -28
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +1068 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +61 -0
- package/src/lib/grok-transcript.ts +167 -0
- package/src/lib/kiro-db.ts +187 -43
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +599 -60
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +63 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +87 -0
- package/src/lib/ui-cache.ts +185 -24
- package/src/lib/ui-export-archive.ts +58 -39
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- package/apps/ui/package.json +0 -65
package/src/lib/ui-cache.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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) =>
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
};
|
package/apps/ui/package.json
DELETED
|
@@ -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
|
-
}
|