spiracha 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -1
- package/README.md +61 -7
- package/apps/ui/AGENTS.md +70 -0
- package/apps/ui/README.md +72 -0
- package/apps/ui/dist/client/assets/_threadId-CAIeH5mq.js +1 -0
- package/apps/ui/dist/client/assets/analytics-CqWZmyV6.js +1 -0
- package/apps/ui/dist/client/assets/checkbox-DXM4lkJq.js +1 -0
- package/apps/ui/dist/client/assets/data-table-DnPYMPCD.js +4 -0
- package/apps/ui/dist/client/assets/delete-confirm-dialog-CcZaRX33.js +11 -0
- package/apps/ui/dist/client/assets/download-DOwxk-cG.js +1 -0
- package/apps/ui/dist/client/assets/es2015-Bm0kEzx2.js +41 -0
- package/apps/ui/dist/client/assets/formatters-C12LmYaa.js +1 -0
- package/apps/ui/dist/client/assets/index-DdJ7ahIt.js +22 -0
- package/apps/ui/dist/client/assets/input-CEsI7EpI.js +1 -0
- package/apps/ui/dist/client/assets/metric-card-9jwBF7rG.js +1 -0
- package/apps/ui/dist/client/assets/page-header-Dr_h1CVv.js +1 -0
- package/apps/ui/dist/client/assets/projects._project-uyNGnpjH.js +1 -0
- package/apps/ui/dist/client/assets/projects._project-zoM8d2nH.js +1 -0
- package/apps/ui/dist/client/assets/projects.index-D1CWVN-O.js +1 -0
- package/apps/ui/dist/client/assets/projects.index-DukMuny6.js +1 -0
- package/apps/ui/dist/client/assets/routes-Gr2Wwh83.js +1 -0
- package/apps/ui/dist/client/assets/select-CFim44gT.js +1 -0
- package/apps/ui/dist/client/assets/settings-DqhyDxo2.js +1 -0
- package/apps/ui/dist/client/assets/styles-CMrP9Jb4.css +1 -0
- package/apps/ui/dist/client/assets/threads._threadId-DT75NiBa.js +1 -0
- package/apps/ui/dist/client/assets/threads._threadId-Df5VXIuZ.js +7 -0
- package/apps/ui/dist/client/favicon.ico +0 -0
- package/apps/ui/dist/client/logo192.png +0 -0
- package/apps/ui/dist/client/logo512.png +0 -0
- package/apps/ui/dist/client/manifest.json +25 -0
- package/apps/ui/dist/client/robots.txt +3 -0
- package/apps/ui/dist/server/assets/__23tanstack-start-plugin-adapters-BzCA6dXo.js +5 -0
- package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-C0V305Nt.js +99 -0
- package/apps/ui/dist/server/assets/_threadId-B6SrBR9E.js +6 -0
- package/apps/ui/dist/server/assets/analytics-BMxW_bZL.js +139 -0
- package/apps/ui/dist/server/assets/button-CmTDnzOn.js +46 -0
- package/apps/ui/dist/server/assets/checkbox-C0hovF41.js +19 -0
- package/apps/ui/dist/server/assets/codex-queries-CAF6HYiG.js +109 -0
- package/apps/ui/dist/server/assets/codex-server-BFZq2Y2O.js +2062 -0
- package/apps/ui/dist/server/assets/data-table-Cdct823O.js +189 -0
- package/apps/ui/dist/server/assets/delete-confirm-dialog-CWqcTXTF.js +139 -0
- package/apps/ui/dist/server/assets/download-C5rkk_Bo.js +289 -0
- package/apps/ui/dist/server/assets/formatters-FJaGZgJk.js +91 -0
- package/apps/ui/dist/server/assets/input-B4tEzctc.js +46 -0
- package/apps/ui/dist/server/assets/loading-panel-DbLdvjtR.js +27 -0
- package/apps/ui/dist/server/assets/metric-card-ByEeLu0r.js +23 -0
- package/apps/ui/dist/server/assets/model-label-B1NWGc65.js +13 -0
- package/apps/ui/dist/server/assets/page-header-CxdZM86z.js +25 -0
- package/apps/ui/dist/server/assets/path-transforms-DL2IwtYd.js +31 -0
- package/apps/ui/dist/server/assets/projects._project-CJ7l0ynC.js +18 -0
- package/apps/ui/dist/server/assets/projects._project-CLSohrBp.js +26 -0
- package/apps/ui/dist/server/assets/projects._project-CcJLp_A8.js +337 -0
- package/apps/ui/dist/server/assets/projects.index-CaplpeMy.js +26 -0
- package/apps/ui/dist/server/assets/projects.index-srtogpuF.js +172 -0
- package/apps/ui/dist/server/assets/router-C_w-haH6.js +307 -0
- package/apps/ui/dist/server/assets/routes-BhbxvJE7.js +34 -0
- package/apps/ui/dist/server/assets/routes-CPe-ppmC.js +169 -0
- package/apps/ui/dist/server/assets/select-GW76p-ld.js +76 -0
- package/apps/ui/dist/server/assets/settings-MvWDgc1u.js +100 -0
- package/apps/ui/dist/server/assets/settings-store-DpEJEQ7M.js +52 -0
- package/apps/ui/dist/server/assets/sqlite-error-LZDrnxdd.js +13 -0
- package/apps/ui/dist/server/assets/start-HeKLHD9b.js +4 -0
- package/apps/ui/dist/server/assets/threads._threadId-BSSK4nkI.js +26 -0
- package/apps/ui/dist/server/assets/threads._threadId-Ba7vv6-K.js +18 -0
- package/apps/ui/dist/server/assets/threads._threadId-euyNckhj.js +1059 -0
- package/apps/ui/dist/server/assets/utils-C_uf36nf.js +8 -0
- package/apps/ui/dist/server/server.js +5678 -0
- package/package.json +53 -7
- package/src/export-chats.ts +4 -18
- package/src/lib/claude-exporter.ts +1 -1
- package/src/lib/codex-analytics.ts +100 -0
- package/src/lib/codex-browser-db.ts +605 -0
- package/src/lib/codex-browser-export.ts +429 -0
- package/src/lib/codex-browser-types.ts +224 -0
- package/src/lib/codex-exporter-cli.ts +6 -1
- package/src/lib/codex-exporter-db.ts +19 -20
- package/src/lib/codex-exporter-transcript.ts +158 -34
- package/src/lib/codex-exporter-types.ts +8 -0
- package/src/lib/codex-thread-cache.ts +58 -0
- package/src/lib/codex-thread-parser.ts +604 -0
- package/src/lib/interactive-cli.ts +10 -25
- package/src/lib/model-label.ts +24 -0
- package/src/lib/native-open.ts +54 -0
- package/src/lib/path-transforms.ts +46 -0
- package/src/lib/shared.ts +15 -1
- package/src/lib/sqlite-error.ts +14 -0
- package/src/lib/sqlite-retry.ts +53 -0
- package/src/lib/ui-cache.ts +96 -0
- package/src/lib/ui-export-files.ts +77 -0
- package/src/mcp-server.ts +1 -0
- package/src/spiracha.ts +16 -4
- package/src/ui-cli.ts +310 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const formatModelLabel = (value: string | null | undefined): string => {
|
|
2
|
+
if (!value) {
|
|
3
|
+
return 'Assistant';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return value
|
|
7
|
+
.split(/[-_\s]+/u)
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.map((part) => {
|
|
10
|
+
const lower = part.toLowerCase();
|
|
11
|
+
if (lower === 'gpt') {
|
|
12
|
+
return 'GPT';
|
|
13
|
+
}
|
|
14
|
+
if (/^[a-z]\d$/u.test(lower)) {
|
|
15
|
+
return lower.toUpperCase();
|
|
16
|
+
}
|
|
17
|
+
if (/^\d+(\.\d+)*$/u.test(part)) {
|
|
18
|
+
return part;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`;
|
|
22
|
+
})
|
|
23
|
+
.join(' ');
|
|
24
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
type NativeOpenTarget = {
|
|
2
|
+
kind: 'path' | 'url';
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const resolveNativeOpenCommand = () => {
|
|
7
|
+
if (process.platform === 'darwin') {
|
|
8
|
+
return {
|
|
9
|
+
argv: (target: NativeOpenTarget) => ['open', target.value],
|
|
10
|
+
label: 'open',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
return {
|
|
16
|
+
argv: (target: NativeOpenTarget) => ['cmd', '/c', 'start', '', target.value],
|
|
17
|
+
label: 'start',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
argv: (target: NativeOpenTarget) => ['xdg-open', target.value],
|
|
23
|
+
label: 'xdg-open',
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const openNatively = async (target: NativeOpenTarget) => {
|
|
28
|
+
const command = resolveNativeOpenCommand();
|
|
29
|
+
const proc = Bun.spawn(command.argv(target), {
|
|
30
|
+
stderr: 'pipe',
|
|
31
|
+
stdout: 'ignore',
|
|
32
|
+
});
|
|
33
|
+
const exitCode = await proc.exited;
|
|
34
|
+
if (exitCode !== 0) {
|
|
35
|
+
const errorText = await new Response(proc.stderr).text();
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Failed to open ${target.value} with ${command.label}: ${errorText.trim() || `exit code ${exitCode}`}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const openPathNatively = async (targetPath: string) => {
|
|
43
|
+
await openNatively({
|
|
44
|
+
kind: 'path',
|
|
45
|
+
value: targetPath,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const openUrlNatively = async (url: string) => {
|
|
50
|
+
await openNatively({
|
|
51
|
+
kind: 'url',
|
|
52
|
+
value: url,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type PathDisplaySettings = {
|
|
2
|
+
convertToProjectRoot: boolean;
|
|
3
|
+
projectPath?: string | null;
|
|
4
|
+
redactUsername: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const escapeForRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
8
|
+
|
|
9
|
+
const toUniquePathVariants = (projectPath: string) => {
|
|
10
|
+
const normalized = projectPath.trim();
|
|
11
|
+
const variants = [normalized, normalized.replaceAll('\\', '/'), normalized.replaceAll('/', '\\')].filter(Boolean);
|
|
12
|
+
return [...new Set(variants)].sort((left, right) => right.length - left.length);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const replaceExactProjectPath = (text: string, projectPath: string) => {
|
|
16
|
+
let result = text;
|
|
17
|
+
|
|
18
|
+
for (const variant of toUniquePathVariants(projectPath)) {
|
|
19
|
+
const escapedVariant = escapeForRegex(variant);
|
|
20
|
+
result = result.replace(new RegExp(`${escapedVariant}(?<separator>[\\\\/])`, 'gu'), '');
|
|
21
|
+
result = result.replace(new RegExp(`${escapedVariant}(?=$|[^A-Za-z0-9._-])`, 'gu'), '.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const redactRemainingUsernames = (text: string) => {
|
|
28
|
+
return text
|
|
29
|
+
.replace(/\/home\/[^/\\]+(?=\/|$)/gu, '~')
|
|
30
|
+
.replace(/\/Users\/[^/\\]+(?=\/|$)/gu, '~')
|
|
31
|
+
.replace(/[A-Za-z]:[\\/]+Users[\\/]+[^\\/]+(?=[\\/]|$)/gu, '~');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const applyPathTransforms = (text: string, settings: PathDisplaySettings): string => {
|
|
35
|
+
let result = text;
|
|
36
|
+
|
|
37
|
+
if (settings.convertToProjectRoot && settings.projectPath) {
|
|
38
|
+
result = replaceExactProjectPath(result, settings.projectPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (settings.redactUsername) {
|
|
42
|
+
result = redactRemainingUsernames(result);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
};
|
package/src/lib/shared.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs';
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
+
import { finished } from 'node:stream/promises';
|
|
7
|
+
import { formatModelLabel as formatSharedModelLabel } from './model-label';
|
|
6
8
|
|
|
7
9
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
8
10
|
|
|
@@ -59,6 +61,8 @@ export const cleanExtractedText = (text: string): string => {
|
|
|
59
61
|
return text.replace(/^\s*<\/?image>\s*$/gm, '').replace(/\n{3,}/g, '\n\n');
|
|
60
62
|
};
|
|
61
63
|
|
|
64
|
+
export const formatModelLabel = formatSharedModelLabel;
|
|
65
|
+
|
|
62
66
|
export const asObject = (value: JsonValue): Record<string, JsonValue> | null => {
|
|
63
67
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
64
68
|
return null;
|
|
@@ -207,6 +211,16 @@ export const writeExportFile = async (outputPath: string, content: string) => {
|
|
|
207
211
|
await Bun.write(outputPath, content);
|
|
208
212
|
};
|
|
209
213
|
|
|
214
|
+
export const createExportWriteStream = async (outputPath: string) => {
|
|
215
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
216
|
+
return createWriteStream(outputPath, { encoding: 'utf8' });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const finalizeExportWriteStream = async (stream: NodeJS.WritableStream) => {
|
|
220
|
+
stream.end();
|
|
221
|
+
await finished(stream);
|
|
222
|
+
};
|
|
223
|
+
|
|
210
224
|
const toMetadataValue = (value: unknown, format: ExportFormat): string => {
|
|
211
225
|
if (Array.isArray(value) || (value && typeof value === 'object')) {
|
|
212
226
|
return JSON.stringify(value);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const SQLITE_RETRYABLE_PATTERNS = [
|
|
2
|
+
/unable to open database file/iu,
|
|
3
|
+
/database is locked/iu,
|
|
4
|
+
/SQLITE_BUSY/iu,
|
|
5
|
+
/SQLITE_CANTOPEN/iu,
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const isRetryableSqliteError = (error: unknown) => {
|
|
9
|
+
if (!(error instanceof Error)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return SQLITE_RETRYABLE_PATTERNS.some((pattern) => pattern.test(error.message));
|
|
14
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRetryableSqliteError } from './sqlite-error';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_RETRY_DELAYS_MS = [40, 120, 250] as const;
|
|
4
|
+
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
5
|
+
|
|
6
|
+
type SyncRetryOptions<T> = {
|
|
7
|
+
action: () => T;
|
|
8
|
+
delaysMs?: readonly number[];
|
|
9
|
+
sleep?: (delayMs: number) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sleepSync = (delayMs: number) => {
|
|
13
|
+
if (delayMs <= 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Atomics.wait(SLEEP_BUFFER, 0, 0, delayMs);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const toRetryExhaustedError = (attemptCount: number, error: unknown) => {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
return new Error(`SQLite operation failed after ${attemptCount} attempts: ${message}`, {
|
|
23
|
+
cause: error,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const shouldRetrySqliteError = (error: unknown, attempt: number, delaysMs: readonly number[]) => {
|
|
28
|
+
return isRetryableSqliteError(error) && attempt < delaysMs.length;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const runWithSqliteRetry = <T>({
|
|
32
|
+
action,
|
|
33
|
+
delaysMs = DEFAULT_RETRY_DELAYS_MS,
|
|
34
|
+
sleep = sleepSync,
|
|
35
|
+
}: SyncRetryOptions<T>): T => {
|
|
36
|
+
let attempt = 0;
|
|
37
|
+
|
|
38
|
+
while (true) {
|
|
39
|
+
try {
|
|
40
|
+
return action();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!shouldRetrySqliteError(error, attempt, delaysMs)) {
|
|
43
|
+
if (isRetryableSqliteError(error)) {
|
|
44
|
+
throw toRetryExhaustedError(attempt + 1, error);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
sleep(delaysMs[attempt] ?? 0);
|
|
50
|
+
attempt += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = path.join(os.tmpdir(), 'spiracha-ui-cache');
|
|
7
|
+
const CACHE_ENVELOPE_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
type CacheEnvelope<T> = {
|
|
10
|
+
value: T;
|
|
11
|
+
version: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ensureCacheDir = async () => {
|
|
15
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
16
|
+
};
|
|
17
|
+
|
|
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`);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const hashCacheKeyParts = (...parts: string[]) => {
|
|
24
|
+
return createHash('sha1').update(parts.join('|')).digest('hex');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getFileFingerprint = async (filePath: string) => {
|
|
28
|
+
const metadata = await stat(filePath);
|
|
29
|
+
return `${filePath}:${metadata.size}:${metadata.mtimeMs}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getCachedJson = async <T>(key: string): Promise<T | null> => {
|
|
33
|
+
await ensureCacheDir();
|
|
34
|
+
const filePath = toCachePath(key);
|
|
35
|
+
const file = Bun.file(filePath);
|
|
36
|
+
if (!(await file.exists())) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let parsed: CacheEnvelope<T> | T;
|
|
41
|
+
try {
|
|
42
|
+
parsed = (await file.json()) as CacheEnvelope<T> | T;
|
|
43
|
+
} catch {
|
|
44
|
+
await rm(filePath, { force: true });
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
parsed &&
|
|
50
|
+
typeof parsed === 'object' &&
|
|
51
|
+
'version' in parsed &&
|
|
52
|
+
(parsed as CacheEnvelope<T>).version === CACHE_ENVELOPE_VERSION &&
|
|
53
|
+
'value' in parsed
|
|
54
|
+
) {
|
|
55
|
+
return (parsed as CacheEnvelope<T>).value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parsed as T;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const setCachedJson = async <T>(key: string, value: T) => {
|
|
62
|
+
await ensureCacheDir();
|
|
63
|
+
const filePath = toCachePath(key);
|
|
64
|
+
const tempPath = `${filePath}.${randomUUID()}.tmp`;
|
|
65
|
+
const envelope: CacheEnvelope<T> = {
|
|
66
|
+
value,
|
|
67
|
+
version: CACHE_ENVELOPE_VERSION,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await Bun.write(tempPath, JSON.stringify(envelope));
|
|
71
|
+
await rename(tempPath, filePath);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const withCachedJson = async <T>(key: string, loader: () => Promise<T>): Promise<T> => {
|
|
75
|
+
const filePath = toCachePath(key);
|
|
76
|
+
const existedBeforeRead = await Bun.file(filePath).exists();
|
|
77
|
+
const cached = await getCachedJson<T>(key);
|
|
78
|
+
if (cached !== null || (existedBeforeRead && (await Bun.file(filePath).exists()))) {
|
|
79
|
+
return cached as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const value = await loader();
|
|
83
|
+
await setCachedJson(key, value);
|
|
84
|
+
return value;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const invalidateCacheByPrefix = async (...prefixes: string[]) => {
|
|
88
|
+
await ensureCacheDir();
|
|
89
|
+
const entries = await readdir(CACHE_DIR);
|
|
90
|
+
|
|
91
|
+
await Promise.all(
|
|
92
|
+
entries
|
|
93
|
+
.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)))
|
|
94
|
+
.map((entry) => rm(path.join(CACHE_DIR, entry), { force: true })),
|
|
95
|
+
);
|
|
96
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const UI_EXPORT_DIR_ENV = 'SPIRACHA_UI_EXPORT_DIR';
|
|
6
|
+
export const UI_EXPORT_URL_PREFIX = '/__exports/';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_UI_EXPORT_DIR = path.join(os.tmpdir(), 'spiracha-ui-exports');
|
|
9
|
+
const DEFAULT_EXPORT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const decodeExportFileName = (value: string) => {
|
|
12
|
+
try {
|
|
13
|
+
return decodeURIComponent(value);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isSafeExportFileName = (value: string) => {
|
|
20
|
+
return value.length > 0 && value === path.basename(value) && !/[\\/]/u.test(value) && !value.includes('\0');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const getUiExportDir = () => {
|
|
24
|
+
return process.env[UI_EXPORT_DIR_ENV]?.trim() || DEFAULT_UI_EXPORT_DIR;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const ensureUiExportDir = async () => {
|
|
28
|
+
const exportDir = getUiExportDir();
|
|
29
|
+
await mkdir(exportDir, { recursive: true });
|
|
30
|
+
await purgeStaleUiExports(exportDir);
|
|
31
|
+
return exportDir;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const buildUiExportDownloadUrl = (filePath: string) => {
|
|
35
|
+
return `${UI_EXPORT_URL_PREFIX}${encodeURIComponent(path.basename(filePath))}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const buildUiExportContentDisposition = (filePath: string) => {
|
|
39
|
+
const fileName = path.basename(filePath);
|
|
40
|
+
return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const purgeStaleUiExports = async (
|
|
44
|
+
exportDir: string = getUiExportDir(),
|
|
45
|
+
maxAgeMs: number = DEFAULT_EXPORT_MAX_AGE_MS,
|
|
46
|
+
) => {
|
|
47
|
+
const entries = await readdir(exportDir, { withFileTypes: true });
|
|
48
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
49
|
+
|
|
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
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const resolveUiExportFilePathFromRequestPath = (pathname: string) => {
|
|
66
|
+
if (!pathname.startsWith(UI_EXPORT_URL_PREFIX)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rawFileName = pathname.slice(UI_EXPORT_URL_PREFIX.length);
|
|
71
|
+
const fileName = decodeExportFileName(rawFileName);
|
|
72
|
+
if (!fileName || !isSafeExportFileName(fileName)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return path.join(getUiExportDir(), fileName);
|
|
77
|
+
};
|
package/src/mcp-server.ts
CHANGED
|
@@ -59,6 +59,7 @@ server.registerTool(
|
|
|
59
59
|
cwdFilter,
|
|
60
60
|
dbPath: expandHome(args.dbPath ?? DEFAULT_DB_PATH),
|
|
61
61
|
flat: args.flat ?? false,
|
|
62
|
+
includeCommentary: true,
|
|
62
63
|
includeTools: args.includeTools ?? false,
|
|
63
64
|
inputDir: expandHome(args.inputDir ?? DEFAULT_INPUT_DIR),
|
|
64
65
|
optimized: args.optimized ?? false,
|
package/src/spiracha.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
import { runExportClaudeCli } from './export-claude';
|
|
5
|
-
|
|
6
|
-
type SpirachaCommandKind = 'codex' | 'claude' | 'help';
|
|
3
|
+
type SpirachaCommandKind = 'codex' | 'claude' | 'help' | 'ui';
|
|
7
4
|
|
|
8
5
|
type SpirachaInvocation = {
|
|
9
6
|
kind: SpirachaCommandKind;
|
|
@@ -21,6 +18,10 @@ export const resolveSpirachaInvocation = (argv: string[]): SpirachaInvocation =>
|
|
|
21
18
|
return { argv: rest, kind: 'codex' };
|
|
22
19
|
}
|
|
23
20
|
|
|
21
|
+
if (firstArg === 'ui') {
|
|
22
|
+
return { argv: rest, kind: 'ui' };
|
|
23
|
+
}
|
|
24
|
+
|
|
24
25
|
if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
|
|
25
26
|
return { argv: [], kind: 'help' };
|
|
26
27
|
}
|
|
@@ -36,10 +37,12 @@ export const getSpirachaHelpText = (): string => {
|
|
|
36
37
|
' spiracha',
|
|
37
38
|
' spiracha codex [Codex options]',
|
|
38
39
|
' spiracha claude [Claude options]',
|
|
40
|
+
' spiracha ui [UI options]',
|
|
39
41
|
'',
|
|
40
42
|
'Commands:',
|
|
41
43
|
' codex Export Codex chats (default when no subcommand is provided)',
|
|
42
44
|
' claude Export a Claude transcript file or export directory',
|
|
45
|
+
' ui Launch the local browser UI for browsing Codex history',
|
|
43
46
|
'',
|
|
44
47
|
'Aliases:',
|
|
45
48
|
' codex-chats',
|
|
@@ -48,6 +51,7 @@ export const getSpirachaHelpText = (): string => {
|
|
|
48
51
|
'For command-specific help:',
|
|
49
52
|
' spiracha codex --help',
|
|
50
53
|
' spiracha claude --help',
|
|
54
|
+
' spiracha ui --help',
|
|
51
55
|
].join('\n');
|
|
52
56
|
};
|
|
53
57
|
|
|
@@ -60,10 +64,18 @@ export const runSpirachaCli = async (argv = process.argv.slice(2)): Promise<void
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
if (invocation.kind === 'claude') {
|
|
67
|
+
const { runExportClaudeCli } = await import('./export-claude');
|
|
63
68
|
await runExportClaudeCli(invocation.argv);
|
|
64
69
|
return;
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
if (invocation.kind === 'ui') {
|
|
73
|
+
const { runUiCli } = await import('./ui-cli');
|
|
74
|
+
await runUiCli(invocation.argv);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { runExportChatsCli } = await import('./export-chats');
|
|
67
79
|
await runExportChatsCli(invocation.argv);
|
|
68
80
|
};
|
|
69
81
|
|