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/apps/ui/vite.config.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
|
-
import { access, constants } from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import tailwindcss from '@tailwindcss/vite';
|
|
5
4
|
import { devtools } from '@tanstack/devtools-vite';
|
|
6
5
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
|
|
7
6
|
import viteReact from '@vitejs/plugin-react';
|
|
8
7
|
import { defineConfig, type Plugin } from 'vite';
|
|
9
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildUiExportContentDisposition,
|
|
10
|
+
resolveReadableUiExportFileFromRequestPath,
|
|
11
|
+
UI_EXPORT_URL_PREFIX,
|
|
12
|
+
} from '../../src/lib/ui-export-files';
|
|
13
|
+
|
|
14
|
+
const uiRoot = __dirname;
|
|
10
15
|
|
|
11
16
|
const getExportContentType = (filePath: string) => {
|
|
12
17
|
if (filePath.endsWith('.zip')) {
|
|
@@ -33,17 +38,14 @@ const spirachaExportFiles = (): Plugin => {
|
|
|
33
38
|
return;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
if (!exportFilePath) {
|
|
41
|
+
const pathname = new URL(req.url, 'http://spiracha.local').pathname;
|
|
42
|
+
if (!pathname.startsWith(UI_EXPORT_URL_PREFIX)) {
|
|
40
43
|
next();
|
|
41
44
|
return;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
} catch {
|
|
47
|
+
const exportFilePath = await resolveReadableUiExportFileFromRequestPath(pathname);
|
|
48
|
+
if (!exportFilePath) {
|
|
47
49
|
res.statusCode = 404;
|
|
48
50
|
res.end('Not Found');
|
|
49
51
|
return;
|
|
@@ -59,7 +61,14 @@ const spirachaExportFiles = (): Plugin => {
|
|
|
59
61
|
return;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
createReadStream(exportFilePath)
|
|
64
|
+
const stream = createReadStream(exportFilePath);
|
|
65
|
+
stream.once('error', () => {
|
|
66
|
+
if (!res.headersSent) {
|
|
67
|
+
res.statusCode = 500;
|
|
68
|
+
}
|
|
69
|
+
res.end('Internal Server Error');
|
|
70
|
+
});
|
|
71
|
+
stream.pipe(res);
|
|
63
72
|
});
|
|
64
73
|
},
|
|
65
74
|
name: 'spiracha-export-files',
|
|
@@ -70,13 +79,14 @@ const config = defineConfig({
|
|
|
70
79
|
plugins: [spirachaExportFiles(), devtools(), tailwindcss(), tanstackStart(), viteReact()],
|
|
71
80
|
resolve: {
|
|
72
81
|
alias: {
|
|
73
|
-
'@spiracha': path.resolve(
|
|
82
|
+
'@spiracha': path.resolve(uiRoot, '..', '..', 'src'),
|
|
74
83
|
},
|
|
75
84
|
tsconfigPaths: true,
|
|
76
85
|
},
|
|
86
|
+
root: uiRoot,
|
|
77
87
|
server: {
|
|
78
88
|
fs: {
|
|
79
|
-
allow: [path.resolve(
|
|
89
|
+
allow: [path.resolve(uiRoot, '..', '..')],
|
|
80
90
|
},
|
|
81
91
|
},
|
|
82
92
|
});
|
package/bin/spiracha.ts
CHANGED
|
@@ -14,6 +14,33 @@ export const buildSpirachaDevServerCommand = (packageRoot = resolveSpirachaPacka
|
|
|
14
14
|
cwd: path.join(packageRoot, 'apps', 'ui'),
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
type SpirachaChildProcess = {
|
|
18
|
+
exited: Promise<number>;
|
|
19
|
+
kill: (signal?: NodeJS.Signals | number) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type SpirachaSignalEmitter = {
|
|
23
|
+
off: (event: NodeJS.Signals, listener: () => void) => unknown;
|
|
24
|
+
on: (event: NodeJS.Signals, listener: () => void) => unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const waitForSpirachaDevServer = async (
|
|
28
|
+
child: SpirachaChildProcess,
|
|
29
|
+
signalEmitter: SpirachaSignalEmitter = process,
|
|
30
|
+
): Promise<number> => {
|
|
31
|
+
const forwardSigint = () => child.kill('SIGINT');
|
|
32
|
+
const forwardSigterm = () => child.kill('SIGTERM');
|
|
33
|
+
signalEmitter.on('SIGINT', forwardSigint);
|
|
34
|
+
signalEmitter.on('SIGTERM', forwardSigterm);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
return await child.exited;
|
|
38
|
+
} finally {
|
|
39
|
+
signalEmitter.off('SIGINT', forwardSigint);
|
|
40
|
+
signalEmitter.off('SIGTERM', forwardSigterm);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
17
44
|
export const runSpirachaDevServer = async (): Promise<number> => {
|
|
18
45
|
const command = buildSpirachaDevServerCommand();
|
|
19
46
|
const proc = Bun.spawn([process.execPath, ...command.args], {
|
|
@@ -24,7 +51,7 @@ export const runSpirachaDevServer = async (): Promise<number> => {
|
|
|
24
51
|
stdout: 'inherit',
|
|
25
52
|
});
|
|
26
53
|
|
|
27
|
-
return proc
|
|
54
|
+
return waitForSpirachaDevServer(proc);
|
|
28
55
|
};
|
|
29
56
|
|
|
30
57
|
if (import.meta.main) {
|
package/package.json
CHANGED
|
@@ -11,37 +11,46 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@tailwindcss/typography": "0.5.20",
|
|
14
|
-
"@tailwindcss/vite": "4.3.
|
|
15
|
-
"@tanstack/devtools-vite": "0.
|
|
14
|
+
"@tailwindcss/vite": "4.3.3",
|
|
15
|
+
"@tanstack/devtools-vite": "0.8.1",
|
|
16
16
|
"@tanstack/match-sorter-utils": "8.19.4",
|
|
17
|
-
"@tanstack/react-devtools": "0.10.
|
|
18
|
-
"@tanstack/react-query": "5.101.
|
|
19
|
-
"@tanstack/react-query-devtools": "5.101.
|
|
20
|
-
"@tanstack/react-router": "1.170.
|
|
17
|
+
"@tanstack/react-devtools": "0.10.8",
|
|
18
|
+
"@tanstack/react-query": "5.101.2",
|
|
19
|
+
"@tanstack/react-query-devtools": "5.101.2",
|
|
20
|
+
"@tanstack/react-router": "1.170.18",
|
|
21
21
|
"@tanstack/react-router-devtools": "1.167.0",
|
|
22
22
|
"@tanstack/react-router-ssr-query": "1.167.1",
|
|
23
|
-
"@tanstack/react-start": "1.168.
|
|
23
|
+
"@tanstack/react-start": "1.168.30",
|
|
24
24
|
"@tanstack/react-table": "8.21.3",
|
|
25
|
-
"@tanstack/react-virtual": "3.14.
|
|
26
|
-
"@tanstack/router-plugin": "1.168.
|
|
27
|
-
"@vitejs/plugin-react": "6.0.
|
|
25
|
+
"@tanstack/react-virtual": "3.14.6",
|
|
26
|
+
"@tanstack/router-plugin": "1.168.22",
|
|
27
|
+
"@vitejs/plugin-react": "6.0.3",
|
|
28
28
|
"class-variance-authority": "0.7.1",
|
|
29
29
|
"clsx": "2.1.1",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
30
|
+
"fflate": "0.8.3",
|
|
31
|
+
"lucide-react": "1.25.0",
|
|
32
|
+
"radix-ui": "1.6.2",
|
|
32
33
|
"react": "19.2.7",
|
|
33
34
|
"react-dom": "19.2.7",
|
|
34
35
|
"tailwind-merge": "3.6.0",
|
|
35
|
-
"tailwindcss": "4.3.
|
|
36
|
+
"tailwindcss": "4.3.3",
|
|
36
37
|
"tw-animate-css": "1.4.0",
|
|
37
|
-
"vite": "8.
|
|
38
|
+
"vite": "8.1.5",
|
|
38
39
|
"zod": "4.4.3"
|
|
39
40
|
},
|
|
40
|
-
"description": "Browse and expose local Codex, Claude Code, Kiro, Qoder, Cursor, Antigravity, and OpenCode conversation history through a local UI and stable data API.",
|
|
41
|
+
"description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, and OpenCode conversation history through a local UI and stable data API.",
|
|
41
42
|
"devDependencies": {
|
|
43
|
+
"@biomejs/biome": "2.5.4",
|
|
44
|
+
"@testing-library/dom": "10.4.1",
|
|
45
|
+
"@testing-library/react": "16.3.2",
|
|
42
46
|
"@types/bun": "^1.3.14",
|
|
43
|
-
"@types/node": "^
|
|
44
|
-
"
|
|
47
|
+
"@types/node": "^26.1.1",
|
|
48
|
+
"@types/react": "19.2.17",
|
|
49
|
+
"@types/react-dom": "19.2.3",
|
|
50
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
51
|
+
"jsdom": "29.1.1",
|
|
52
|
+
"typescript": "^7.0.2",
|
|
53
|
+
"vitest": "4.1.10"
|
|
45
54
|
},
|
|
46
55
|
"engines": {
|
|
47
56
|
"bun": ">=1.3.14"
|
|
@@ -63,7 +72,6 @@
|
|
|
63
72
|
"files": [
|
|
64
73
|
"bin/spiracha.ts",
|
|
65
74
|
"apps/ui/components.json",
|
|
66
|
-
"apps/ui/package.json",
|
|
67
75
|
"apps/ui/public/**/*",
|
|
68
76
|
"apps/ui/src/**/*",
|
|
69
77
|
"!apps/ui/src/**/*.vitest.ts",
|
|
@@ -78,9 +86,13 @@
|
|
|
78
86
|
"AGENTS.md"
|
|
79
87
|
],
|
|
80
88
|
"homepage": "https://github.com/ragaeeb/spiracha",
|
|
89
|
+
"imports": {
|
|
90
|
+
"#/*": "./apps/ui/src/*"
|
|
91
|
+
},
|
|
81
92
|
"keywords": [
|
|
82
93
|
"codex",
|
|
83
94
|
"claude",
|
|
95
|
+
"grok",
|
|
84
96
|
"kiro",
|
|
85
97
|
"qoder",
|
|
86
98
|
"cursor",
|
|
@@ -99,21 +111,19 @@
|
|
|
99
111
|
"url": "git+https://github.com/ragaeeb/spiracha.git"
|
|
100
112
|
},
|
|
101
113
|
"scripts": {
|
|
102
|
-
"build": "bun run typecheck && bun
|
|
114
|
+
"build": "bun run typecheck && bun --cwd apps/ui --bun vite build",
|
|
103
115
|
"coverage": "bun run coverage:root && bun run coverage:ui",
|
|
104
116
|
"coverage:root": "bun test --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run ./src/coverage-check.ts root",
|
|
105
|
-
"coverage:ui": "
|
|
117
|
+
"coverage:ui": "vitest run --coverage --config apps/ui/vitest.config.ts && bun run ./src/coverage-check.ts ui",
|
|
106
118
|
"format": "biome check . --write && biome lint . --write",
|
|
107
119
|
"lint": "biome check .",
|
|
108
|
-
"start": "bun
|
|
120
|
+
"start": "bun --cwd apps/ui --bun vite dev --host 127.0.0.1 --port 3000",
|
|
121
|
+
"test:ui": "vitest run --config apps/ui/vitest.config.ts",
|
|
109
122
|
"typecheck": "bun run typecheck:root && bun run typecheck:ui",
|
|
110
123
|
"typecheck:root": "bunx tsc --noEmit",
|
|
111
|
-
"typecheck:ui": "
|
|
112
|
-
"ui:preview": "bun
|
|
124
|
+
"typecheck:ui": "bunx tsc --noEmit -p apps/ui/tsconfig.json",
|
|
125
|
+
"ui:preview": "bun --cwd apps/ui --bun vite preview"
|
|
113
126
|
},
|
|
114
127
|
"type": "module",
|
|
115
|
-
"version": "2.
|
|
116
|
-
"workspaces": [
|
|
117
|
-
"apps/*"
|
|
118
|
-
]
|
|
128
|
+
"version": "2.2.0"
|
|
119
129
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { mapWithConcurrency } from './lib/concurrency';
|
|
1
2
|
import {
|
|
3
|
+
deleteConversation as deleteLocalConversation,
|
|
4
|
+
deleteConversations as deleteLocalConversations,
|
|
2
5
|
getConversation as getLocalConversation,
|
|
3
6
|
listConversationSources as listLocalConversationSources,
|
|
4
7
|
listConversationsForPath as listLocalConversationsForPath,
|
|
@@ -11,10 +14,17 @@ import type {
|
|
|
11
14
|
ConversationMessageSelector,
|
|
12
15
|
ConversationPage,
|
|
13
16
|
ConversationSourceInfo,
|
|
17
|
+
ConversationZipDownload,
|
|
18
|
+
DeleteConversationOptions,
|
|
19
|
+
DeleteConversationResult,
|
|
20
|
+
DeleteConversationsOptions,
|
|
21
|
+
DeleteConversationsResult,
|
|
22
|
+
ExportConversationsZipOptions,
|
|
14
23
|
GetConversationOptions,
|
|
15
24
|
ListConversationsForPathOptions,
|
|
16
25
|
ResolvedConversationRef,
|
|
17
26
|
} from './lib/conversation-data/types';
|
|
27
|
+
import { createConversationMarkdownZip } from './lib/conversation-zip-export';
|
|
18
28
|
|
|
19
29
|
export type {
|
|
20
30
|
ConversationDataLocations,
|
|
@@ -28,6 +38,12 @@ export type {
|
|
|
28
38
|
ConversationPathMatch,
|
|
29
39
|
ConversationSource,
|
|
30
40
|
ConversationSourceInfo,
|
|
41
|
+
ConversationZipDownload,
|
|
42
|
+
DeleteConversationOptions,
|
|
43
|
+
DeleteConversationResult,
|
|
44
|
+
DeleteConversationsOptions,
|
|
45
|
+
DeleteConversationsResult,
|
|
46
|
+
ExportConversationsZipOptions,
|
|
31
47
|
GetConversationOptions,
|
|
32
48
|
ListConversationsForPathOptions,
|
|
33
49
|
ResolvedConversationRef,
|
|
@@ -36,9 +52,11 @@ export type {
|
|
|
36
52
|
type HttpEnvelope<T> = {
|
|
37
53
|
data?: T;
|
|
38
54
|
error?: {
|
|
55
|
+
code?: string | null;
|
|
39
56
|
message?: string | null;
|
|
40
57
|
} | null;
|
|
41
58
|
meta?: {
|
|
59
|
+
has_next?: boolean | null;
|
|
42
60
|
hasNext?: boolean | null;
|
|
43
61
|
nextCursor?: string | null;
|
|
44
62
|
next_cursor?: string | null;
|
|
@@ -70,7 +88,10 @@ export type CreateConversationClientOptions = HttpConversationClientOptions | Lo
|
|
|
70
88
|
export type ExportConversationMarkdownOptions = GetConversationOptions;
|
|
71
89
|
|
|
72
90
|
export type ConversationClient = {
|
|
91
|
+
deleteConversation: (options: DeleteConversationOptions) => Promise<DeleteConversationResult | null>;
|
|
92
|
+
deleteConversations: (options: DeleteConversationsOptions) => Promise<DeleteConversationsResult | null>;
|
|
73
93
|
exportConversationMarkdown: (options: ExportConversationMarkdownOptions) => Promise<string | null>;
|
|
94
|
+
exportConversationsZip: (options: ExportConversationsZipOptions) => Promise<ConversationZipDownload | null>;
|
|
74
95
|
getConversation: (options: GetConversationOptions) => Promise<ConversationDetail | null>;
|
|
75
96
|
listConversations: (options: ListConversationsForPathOptions) => Promise<ConversationPage>;
|
|
76
97
|
listSources: () => Promise<ConversationSourceInfo[]>;
|
|
@@ -178,19 +199,53 @@ const fetchJson = async <T>(url: URL, init?: RequestInit): Promise<HttpEnvelope<
|
|
|
178
199
|
return readJsonEnvelope(response);
|
|
179
200
|
};
|
|
180
201
|
|
|
202
|
+
const isMissingConversationResponse = async (response: Response): Promise<boolean> => {
|
|
203
|
+
if (response.status !== 404) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const envelope = (await response.clone().json()) as HttpEnvelope<unknown>;
|
|
209
|
+
return envelope.error?.code === 'conversation_not_found';
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
181
215
|
const fetchJsonOrNull = async <T>(url: URL, init?: RequestInit): Promise<HttpEnvelope<T> | null> => {
|
|
182
216
|
const response = await fetchResponse(url, init);
|
|
183
|
-
if (response
|
|
217
|
+
if (await isMissingConversationResponse(response)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await assertOkResponse(response);
|
|
222
|
+
return readJsonEnvelope(response);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const fetchDeleteJsonOrNull = async <T>(url: URL, init?: RequestInit): Promise<HttpEnvelope<T> | null> => {
|
|
226
|
+
const response = await fetchResponse(url, init);
|
|
227
|
+
if (await isMissingConversationResponse(response)) {
|
|
184
228
|
return null;
|
|
185
229
|
}
|
|
186
230
|
|
|
231
|
+
if (response.status === 405) {
|
|
232
|
+
try {
|
|
233
|
+
const envelope = (await response.clone().json()) as HttpEnvelope<T>;
|
|
234
|
+
if (envelope.error?.code === 'unsupported_operation') {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Let the normal error path report malformed error responses.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
187
242
|
await assertOkResponse(response);
|
|
188
243
|
return readJsonEnvelope(response);
|
|
189
244
|
};
|
|
190
245
|
|
|
191
246
|
const fetchTextOrNull = async (url: URL, init?: RequestInit): Promise<string | null> => {
|
|
192
247
|
const response = await fetchResponse(url, init);
|
|
193
|
-
if (response
|
|
248
|
+
if (await isMissingConversationResponse(response)) {
|
|
194
249
|
return null;
|
|
195
250
|
}
|
|
196
251
|
|
|
@@ -198,6 +253,34 @@ const fetchTextOrNull = async (url: URL, init?: RequestInit): Promise<string | n
|
|
|
198
253
|
return response.text();
|
|
199
254
|
};
|
|
200
255
|
|
|
256
|
+
const fileNameFromContentDisposition = (contentDisposition: string | null, fallback: string) => {
|
|
257
|
+
const encodedMatch = contentDisposition?.match(/filename\*=UTF-8''([^;]+)/iu);
|
|
258
|
+
if (encodedMatch?.[1]) {
|
|
259
|
+
try {
|
|
260
|
+
return decodeURIComponent(encodedMatch[1]);
|
|
261
|
+
} catch {
|
|
262
|
+
return fallback;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const quotedMatch = contentDisposition?.match(/filename="([^"]+)"/iu);
|
|
267
|
+
return quotedMatch?.[1] || fallback;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const fetchZipOrNull = async (url: URL, init?: RequestInit): Promise<ConversationZipDownload | null> => {
|
|
271
|
+
const response = await fetchResponse(url, init);
|
|
272
|
+
if (await isMissingConversationResponse(response)) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await assertOkResponse(response);
|
|
277
|
+
return {
|
|
278
|
+
blob: await response.blob(),
|
|
279
|
+
fileName: fileNameFromContentDisposition(response.headers.get('Content-Disposition'), 'conversations.zip'),
|
|
280
|
+
mimeType: 'application/zip',
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
|
|
201
284
|
const requireData = <T>(envelope: HttpEnvelope<T>, label: string): T => {
|
|
202
285
|
if (envelope.data === undefined) {
|
|
203
286
|
throw new SpirachaClientError(`Spiracha API response did not include ${label}.`);
|
|
@@ -215,13 +298,20 @@ const normalizePage = (envelope: HttpEnvelope<ConversationDetail[]>): Conversati
|
|
|
215
298
|
return {
|
|
216
299
|
data,
|
|
217
300
|
meta: {
|
|
218
|
-
hasNext: envelope.meta?.hasNext === true,
|
|
301
|
+
hasNext: envelope.meta?.has_next === true || envelope.meta?.hasNext === true,
|
|
219
302
|
nextCursor: envelope.meta?.nextCursor ?? envelope.meta?.next_cursor ?? null,
|
|
220
303
|
},
|
|
221
304
|
};
|
|
222
305
|
};
|
|
223
306
|
|
|
224
|
-
const makeHttpUrl = (baseUrl: URL, pathname: string): URL =>
|
|
307
|
+
const makeHttpUrl = (baseUrl: URL, pathname: string): URL => {
|
|
308
|
+
const url = new URL(baseUrl);
|
|
309
|
+
const basePath = url.pathname.replace(/\/+$/u, '');
|
|
310
|
+
url.pathname = `${basePath}/${pathname.replace(/^\/+/, '')}`;
|
|
311
|
+
url.search = '';
|
|
312
|
+
url.hash = '';
|
|
313
|
+
return url;
|
|
314
|
+
};
|
|
225
315
|
|
|
226
316
|
const rejectHttpLocations = (locations: ConversationDataLocations | undefined): void => {
|
|
227
317
|
if (locations) {
|
|
@@ -229,11 +319,63 @@ const rejectHttpLocations = (locations: ConversationDataLocations | undefined):
|
|
|
229
319
|
}
|
|
230
320
|
};
|
|
231
321
|
|
|
322
|
+
const buildBatchBody = ({ ids, messageSelector, outputFormat, source }: ExportConversationsZipOptions) => ({
|
|
323
|
+
ids,
|
|
324
|
+
message_selector: messageSelector,
|
|
325
|
+
output_format: outputFormat,
|
|
326
|
+
source,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const exportLocalConversationsZip = async (
|
|
330
|
+
exportOptions: ExportConversationsZipOptions,
|
|
331
|
+
locations: ConversationDataLocations | undefined,
|
|
332
|
+
) => {
|
|
333
|
+
const options = withDefaultLocations(exportOptions, locations);
|
|
334
|
+
if (options.ids.length > 200) {
|
|
335
|
+
throw new SpirachaClientError('At most 200 conversation ids may be exported at once.');
|
|
336
|
+
}
|
|
337
|
+
const conversations = await mapWithConcurrency(options.ids, 4, (id) =>
|
|
338
|
+
getLocalConversation({
|
|
339
|
+
id,
|
|
340
|
+
locations: options.locations,
|
|
341
|
+
messageSelector: options.messageSelector ?? 'all',
|
|
342
|
+
source: options.source,
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (conversations.some((conversation) => conversation === null)) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return createConversationMarkdownZip({
|
|
351
|
+
entries: conversations.map((conversation, index) => {
|
|
352
|
+
const resolvedConversation = conversation!;
|
|
353
|
+
return {
|
|
354
|
+
fallbackBaseName: `${options.source}-${options.ids[index]}`,
|
|
355
|
+
markdown: renderLocalConversationMarkdown(resolvedConversation, {
|
|
356
|
+
messageSelector: options.messageSelector ?? 'all',
|
|
357
|
+
}),
|
|
358
|
+
title: resolvedConversation.title,
|
|
359
|
+
};
|
|
360
|
+
}),
|
|
361
|
+
fileBaseName: `${options.source}-conversations-${options.ids.length}`,
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
|
|
232
365
|
const makeLocalClient = (options: LocalConversationClientOptions): ConversationClient => ({
|
|
366
|
+
deleteConversation: (deleteOptions) =>
|
|
367
|
+
deleteLocalConversation(withDefaultLocations(deleteOptions, options.locations)),
|
|
368
|
+
deleteConversations: (deleteOptions) =>
|
|
369
|
+
deleteLocalConversations(withDefaultLocations(deleteOptions, options.locations)),
|
|
233
370
|
exportConversationMarkdown: async (getOptions) => {
|
|
234
371
|
const conversation = await getLocalConversation(withDefaultLocations(getOptions, options.locations));
|
|
235
|
-
return conversation
|
|
372
|
+
return conversation
|
|
373
|
+
? renderLocalConversationMarkdown(conversation, {
|
|
374
|
+
messageSelector: getOptions.messageSelector,
|
|
375
|
+
})
|
|
376
|
+
: null;
|
|
236
377
|
},
|
|
378
|
+
exportConversationsZip: (exportOptions) => exportLocalConversationsZip(exportOptions, options.locations),
|
|
237
379
|
getConversation: (getOptions) => getLocalConversation(withDefaultLocations(getOptions, options.locations)),
|
|
238
380
|
listConversations: (listOptions) =>
|
|
239
381
|
listLocalConversationsForPath(withDefaultLocations(listOptions, options.locations)),
|
|
@@ -245,6 +387,34 @@ const makeHttpClient = (options: HttpConversationClientOptions): ConversationCli
|
|
|
245
387
|
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
246
388
|
|
|
247
389
|
return {
|
|
390
|
+
deleteConversation: async (deleteOptions) => {
|
|
391
|
+
rejectHttpLocations(deleteOptions.locations);
|
|
392
|
+
const { id, source } = deleteOptions;
|
|
393
|
+
const url = makeHttpUrl(baseUrl, `/api/v1/conversations/${source}/${encodeURIComponent(id)}`);
|
|
394
|
+
const envelope = await fetchDeleteJsonOrNull<DeleteConversationResult>(url, { method: 'DELETE' });
|
|
395
|
+
if (!envelope) {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
return requireData(envelope, 'a delete result');
|
|
399
|
+
},
|
|
400
|
+
deleteConversations: async (deleteOptions) => {
|
|
401
|
+
rejectHttpLocations(deleteOptions.locations);
|
|
402
|
+
const envelope = await fetchDeleteJsonOrNull<DeleteConversationsResult>(
|
|
403
|
+
makeHttpUrl(baseUrl, '/api/v1/conversations/delete'),
|
|
404
|
+
{
|
|
405
|
+
body: JSON.stringify({
|
|
406
|
+
ids: deleteOptions.ids,
|
|
407
|
+
source: deleteOptions.source,
|
|
408
|
+
}),
|
|
409
|
+
headers: { 'Content-Type': 'application/json' },
|
|
410
|
+
method: 'POST',
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
if (!envelope) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
return requireData(envelope, 'a delete result');
|
|
417
|
+
},
|
|
248
418
|
exportConversationMarkdown: async (getOptions) => {
|
|
249
419
|
rejectHttpLocations(getOptions.locations);
|
|
250
420
|
const { id, messageSelector, source } = getOptions;
|
|
@@ -252,6 +422,14 @@ const makeHttpClient = (options: HttpConversationClientOptions): ConversationCli
|
|
|
252
422
|
appendMessageSelector(url, messageSelector);
|
|
253
423
|
return fetchTextOrNull(url);
|
|
254
424
|
},
|
|
425
|
+
exportConversationsZip: async (exportOptions) => {
|
|
426
|
+
rejectHttpLocations(exportOptions.locations);
|
|
427
|
+
return fetchZipOrNull(makeHttpUrl(baseUrl, '/api/v1/conversations/export'), {
|
|
428
|
+
body: JSON.stringify(buildBatchBody(exportOptions)),
|
|
429
|
+
headers: { 'Content-Type': 'application/json' },
|
|
430
|
+
method: 'POST',
|
|
431
|
+
});
|
|
432
|
+
},
|
|
255
433
|
getConversation: async (getOptions) => {
|
|
256
434
|
rejectHttpLocations(getOptions.locations);
|
|
257
435
|
const { id, messageSelector, source } = getOptions;
|