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,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 { buildUiExportContentDisposition, resolveUiExportFilePathFromRequestPath } from '../../src/lib/ui-export-files';
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 exportFilePath = resolveUiExportFilePathFromRequestPath(
37
- new URL(req.url, 'http://spiracha.local').pathname,
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
- try {
45
- await access(exportFilePath, constants.R_OK);
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).pipe(res);
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(__dirname, '..', '..', 'src'),
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(__dirname, '..', '..')],
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.exited;
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.1",
15
- "@tanstack/devtools-vite": "0.7.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.5",
18
- "@tanstack/react-query": "5.101.0",
19
- "@tanstack/react-query-devtools": "5.101.0",
20
- "@tanstack/react-router": "1.170.15",
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.25",
23
+ "@tanstack/react-start": "1.168.30",
24
24
  "@tanstack/react-table": "8.21.3",
25
- "@tanstack/react-virtual": "3.14.2",
26
- "@tanstack/router-plugin": "1.168.18",
27
- "@vitejs/plugin-react": "6.0.2",
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
- "lucide-react": "1.18.0",
31
- "radix-ui": "1.5.0",
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.1",
36
+ "tailwindcss": "4.3.3",
36
37
  "tw-animate-css": "1.4.0",
37
- "vite": "8.0.16",
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": "^25.9.3",
44
- "typescript": "^6.0.3"
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 run --cwd apps/ui build",
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": "bun run --cwd apps/ui coverage && bun run ./src/coverage-check.ts 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 run --cwd apps/ui dev",
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": "bun run --cwd apps/ui typecheck",
112
- "ui:preview": "bun run --cwd apps/ui preview"
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.0.0",
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.status === 404) {
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.status === 404) {
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 => new URL(pathname, baseUrl);
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 ? renderLocalConversationMarkdown(conversation) : null;
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;