spiracha 2.0.0 → 2.1.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 (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
@@ -88,10 +88,11 @@ const renderTextPart = (
88
88
  options: OpenCodeExportOptions,
89
89
  finalAssistantTextPartIds: Set<string>,
90
90
  ): string => {
91
+ const rawText = part.text ?? '';
91
92
  const { reasoningBlocks, visibleText } =
92
93
  part.role === 'assistant'
93
- ? splitOpenCodeThinkTaggedText(part.text ?? '')
94
- : { reasoningBlocks: [], visibleText: part.text ?? '' };
94
+ ? splitOpenCodeThinkTaggedText(rawText)
95
+ : { reasoningBlocks: [], visibleText: rawText };
95
96
  const sections: string[] = [];
96
97
 
97
98
  if (options.includeCommentary) {
@@ -119,7 +120,8 @@ const renderReasoningPart = (part: OpenCodeTranscriptPart, options: OpenCodeExpo
119
120
  return '';
120
121
  }
121
122
 
122
- const { reasoningBlocks, visibleText } = splitOpenCodeThinkTaggedText(part.text ?? '');
123
+ const rawText = part.text ?? '';
124
+ const { reasoningBlocks, visibleText } = splitOpenCodeThinkTaggedText(rawText);
123
125
  const text = cleanExtractedText([...reasoningBlocks, visibleText].filter(Boolean).join('\n\n')).trim();
124
126
  return text ? renderSection('Reasoning', text, options.outputFormat) : '';
125
127
  };
@@ -143,8 +145,9 @@ const renderToolPart = (part: OpenCodeTranscriptPart, options: OpenCodeExportOpt
143
145
  if (part.argumentsText?.trim()) {
144
146
  lines.push('', 'Input:', '', renderCodeBlock(part.argumentsText.trim(), options.outputFormat));
145
147
  }
146
- if (part.outputText?.trim()) {
147
- lines.push('', 'Output:', '', renderCodeBlock(truncateOutput(part.outputText.trim()), options.outputFormat));
148
+ const outputText = part.outputText ?? '';
149
+ if (outputText.trim()) {
150
+ lines.push('', 'Output:', '', renderCodeBlock(truncateOutput(outputText.trim()), options.outputFormat));
148
151
  }
149
152
 
150
153
  return renderSection('Tool Call', lines.join('\n'), options.outputFormat);
package/src/lib/shared.ts CHANGED
@@ -86,6 +86,18 @@ export const cleanExtractedText = (text: string): string => {
86
86
  return text.replace(/^\s*<\/?image>\s*$/gm, '').replace(/\n{3,}/g, '\n\n');
87
87
  };
88
88
 
89
+ const CODEX_APP_DIRECTIVE_PATTERN =
90
+ /^::(?:code-comment|created-thread|git-commit|git-create-branch|git-create-pr|git-push|git-stage)\{.*\}\s*$/u;
91
+
92
+ export const stripCodexAppDirectiveLines = (text: string): string => {
93
+ return text
94
+ .split('\n')
95
+ .filter((line) => !CODEX_APP_DIRECTIVE_PATTERN.test(line.trim()))
96
+ .join('\n')
97
+ .replace(/\n{3,}/g, '\n\n')
98
+ .trim();
99
+ };
100
+
89
101
  export const formatModelLabel = formatSharedModelLabel;
90
102
 
91
103
  export const asObject = (value: JsonValue): Record<string, JsonValue> | null => {
@@ -0,0 +1,82 @@
1
+ import { createConcurrencyLimiter } from './concurrency';
2
+
3
+ const DEFAULT_TRANSCRIPT_LOAD_CONCURRENCY = 3;
4
+ const MAX_TRANSCRIPT_LOAD_CONCURRENCY = 16;
5
+ type TranscriptLoadLogContext = {
6
+ id?: string;
7
+ path?: string;
8
+ source?: string;
9
+ };
10
+
11
+ let nextTranscriptLoadId = 1;
12
+ let activeTranscriptLoads = 0;
13
+ let queuedTranscriptLoads = 0;
14
+
15
+ export const resolveTranscriptLoadConcurrency = (value = process.env.SPIRACHA_TRANSCRIPT_LOAD_CONCURRENCY): number => {
16
+ const parsed = Number.parseInt(value ?? '', 10);
17
+ return Number.isFinite(parsed) && parsed > 0
18
+ ? Math.min(parsed, MAX_TRANSCRIPT_LOAD_CONCURRENCY)
19
+ : DEFAULT_TRANSCRIPT_LOAD_CONCURRENCY;
20
+ };
21
+
22
+ const transcriptLoadLimiter = createConcurrencyLimiter(resolveTranscriptLoadConcurrency());
23
+
24
+ const shouldLogTranscriptLoads = () => process.env.SPIRACHA_TRANSCRIPT_LOAD_LOGS !== '0';
25
+
26
+ const logTranscriptLoad = (event: string, details: Record<string, unknown>) => {
27
+ if (shouldLogTranscriptLoads()) {
28
+ console.info(`[spiracha:transcript-load] ${event}`, details);
29
+ }
30
+ };
31
+
32
+ export const runWithTranscriptLoadLimit = async <T>(
33
+ loader: () => Promise<T>,
34
+ context: TranscriptLoadLogContext = {},
35
+ ): Promise<T> => {
36
+ const loadId = nextTranscriptLoadId;
37
+ nextTranscriptLoadId += 1;
38
+ queuedTranscriptLoads += 1;
39
+ const queuedAt = Date.now();
40
+
41
+ return transcriptLoadLimiter(async () => {
42
+ queuedTranscriptLoads -= 1;
43
+ activeTranscriptLoads += 1;
44
+ const startedAt = Date.now();
45
+ logTranscriptLoad('start', {
46
+ active: activeTranscriptLoads,
47
+ id: context.id,
48
+ loadId,
49
+ path: context.path,
50
+ queued: queuedTranscriptLoads,
51
+ source: context.source,
52
+ waitMs: startedAt - queuedAt,
53
+ });
54
+
55
+ try {
56
+ return await loader();
57
+ } catch (error) {
58
+ logTranscriptLoad('error', {
59
+ active: activeTranscriptLoads,
60
+ durationMs: Date.now() - startedAt,
61
+ error: error instanceof Error ? error.message : String(error),
62
+ id: context.id,
63
+ loadId,
64
+ path: context.path,
65
+ queued: queuedTranscriptLoads,
66
+ source: context.source,
67
+ });
68
+ throw error;
69
+ } finally {
70
+ activeTranscriptLoads -= 1;
71
+ logTranscriptLoad('finish', {
72
+ active: activeTranscriptLoads,
73
+ durationMs: Date.now() - startedAt,
74
+ id: context.id,
75
+ loadId,
76
+ path: context.path,
77
+ queued: queuedTranscriptLoads,
78
+ source: context.source,
79
+ });
80
+ }
81
+ });
82
+ };
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
2
+ import { chmod, mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
@@ -11,8 +11,13 @@ type CacheEnvelope<T> = {
11
11
  version: number;
12
12
  };
13
13
 
14
+ type CacheReadResult<T> = { hit: true; value: T } | { hit: false };
15
+
16
+ const inFlightCacheLoads = new Map<string, Promise<unknown>>();
17
+
14
18
  const ensureCacheDir = async () => {
15
- await mkdir(CACHE_DIR, { recursive: true });
19
+ await mkdir(CACHE_DIR, { mode: 0o700, recursive: true });
20
+ await chmod(CACHE_DIR, 0o700);
16
21
  };
17
22
 
18
23
  const toCachePath = (key: string) => {
@@ -41,12 +46,12 @@ export const getFileFingerprint = async (filePath: string) => {
41
46
  return `${filePath}:${metadata.size}:${metadata.mtimeMs}`;
42
47
  };
43
48
 
44
- export const getCachedJson = async <T>(key: string): Promise<T | null> => {
49
+ const readCachedJson = async <T>(key: string): Promise<CacheReadResult<T>> => {
45
50
  await ensureCacheDir();
46
51
  const filePath = toCachePath(key);
47
52
  const file = Bun.file(filePath);
48
53
  if (!(await file.exists())) {
49
- return null;
54
+ return { hit: false };
50
55
  }
51
56
 
52
57
  let parsed: CacheEnvelope<T> | T;
@@ -54,7 +59,7 @@ export const getCachedJson = async <T>(key: string): Promise<T | null> => {
54
59
  parsed = (await file.json()) as CacheEnvelope<T> | T;
55
60
  } catch {
56
61
  await rm(filePath, { force: true });
57
- return null;
62
+ return { hit: false };
58
63
  }
59
64
 
60
65
  if (
@@ -64,11 +69,16 @@ export const getCachedJson = async <T>(key: string): Promise<T | null> => {
64
69
  (parsed as CacheEnvelope<T>).version === CACHE_ENVELOPE_VERSION &&
65
70
  'value' in parsed
66
71
  ) {
67
- return (parsed as CacheEnvelope<T>).value;
72
+ return { hit: true, value: (parsed as CacheEnvelope<T>).value };
68
73
  }
69
74
 
70
75
  await rm(filePath, { force: true });
71
- return null;
76
+ return { hit: false };
77
+ };
78
+
79
+ export const getCachedJson = async <T>(key: string): Promise<T | null> => {
80
+ const cached = await readCachedJson<T>(key);
81
+ return cached.hit ? cached.value : null;
72
82
  };
73
83
 
74
84
  export const setCachedJson = async <T>(key: string, value: T) => {
@@ -80,21 +90,37 @@ export const setCachedJson = async <T>(key: string, value: T) => {
80
90
  version: CACHE_ENVELOPE_VERSION,
81
91
  };
82
92
 
83
- await Bun.write(tempPath, JSON.stringify(envelope));
84
- await rename(tempPath, filePath);
93
+ try {
94
+ await Bun.write(tempPath, JSON.stringify(envelope));
95
+ await rename(tempPath, filePath);
96
+ } finally {
97
+ await rm(tempPath, { force: true });
98
+ }
85
99
  };
86
100
 
87
101
  export const withCachedJson = async <T>(key: string, loader: () => Promise<T>): Promise<T> => {
88
- const filePath = toCachePath(key);
89
- const existedBeforeRead = await Bun.file(filePath).exists();
90
- const cached = await getCachedJson<T>(key);
91
- if (cached !== null || (existedBeforeRead && (await Bun.file(filePath).exists()))) {
92
- return cached as T;
102
+ const cached = await readCachedJson<T>(key);
103
+ if (cached.hit) {
104
+ return cached.value;
105
+ }
106
+
107
+ const inFlight = inFlightCacheLoads.get(key);
108
+ if (inFlight) {
109
+ return (await inFlight) as T;
93
110
  }
94
111
 
95
- const value = await loader();
96
- await setCachedJson(key, value);
97
- return value;
112
+ const load = (async () => {
113
+ const value = await loader();
114
+ await setCachedJson(key, value);
115
+ return value;
116
+ })();
117
+ inFlightCacheLoads.set(key, load);
118
+
119
+ try {
120
+ return await load;
121
+ } finally {
122
+ inFlightCacheLoads.delete(key);
123
+ }
98
124
  };
99
125
 
100
126
  export const invalidateCacheByPrefix = async (...prefixes: string[]) => {
@@ -1,5 +1,24 @@
1
1
  import type { ExportFormat } from './shared';
2
2
 
3
+ type BatchExportNameEntry = {
4
+ cwd: string | null;
5
+ updatedAtMs: number | null;
6
+ };
7
+
8
+ type ConversationExportNameEntry = BatchExportNameEntry & {
9
+ id: string;
10
+ };
11
+
12
+ const getPortablePathBasename = (value: string): string => {
13
+ const trimmed = value.replace(/[\\/]+$/u, '');
14
+ if (!trimmed) {
15
+ return '';
16
+ }
17
+
18
+ const separatorIndex = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
19
+ return separatorIndex === -1 ? trimmed : trimmed.slice(separatorIndex + 1);
20
+ };
21
+
3
22
  export const sanitizeExportFileName = (value: string) => {
4
23
  return value
5
24
  .replace(/[<>:"/\\|?*\u0000-\u001f]/gu, ' ')
@@ -18,6 +37,47 @@ export const resolveUniqueExportFileBaseName = (baseName: string, usedCounts: Ma
18
37
  return count === 1 ? baseName : `${baseName}-${count}`;
19
38
  };
20
39
 
40
+ const formatBatchExportDate = (value: number) => {
41
+ const date = new Date(value);
42
+ const year = date.getUTCFullYear();
43
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
44
+ const day = String(date.getUTCDate()).padStart(2, '0');
45
+ const hours = String(date.getUTCHours()).padStart(2, '0');
46
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
47
+ return `${year}-${month}-${day}-${hours}${minutes}`;
48
+ };
49
+
50
+ const resolveExportProjectName = (cwd: string | null, fallbackProjectName: string) => {
51
+ return sanitizeExportFileName(getPortablePathBasename(cwd ?? '') || fallbackProjectName) || 'threads';
52
+ };
53
+
54
+ export const buildConversationExportBaseName = (
55
+ { cwd, id, updatedAtMs }: ConversationExportNameEntry,
56
+ fallbackProjectName: string,
57
+ ) => {
58
+ const projectName = resolveExportProjectName(cwd, fallbackProjectName);
59
+ const shortId = sanitizeExportFileName(id).slice(0, 8) || 'conversation';
60
+ return Number.isFinite(updatedAtMs) && (updatedAtMs ?? 0) > 0
61
+ ? `${projectName}-${formatBatchExportDate(updatedAtMs!)}-${shortId}`
62
+ : `${projectName}-${shortId}`;
63
+ };
64
+
65
+ export const buildBatchExportBaseName = (entries: BatchExportNameEntry[], fallbackProjectName: string) => {
66
+ if (entries.length === 0) {
67
+ throw new Error('No conversations selected for export');
68
+ }
69
+
70
+ const firstCwd = entries.find((entry) => entry.cwd?.trim())?.cwd ?? null;
71
+ const projectName = resolveExportProjectName(firstCwd, fallbackProjectName);
72
+ const latestUpdatedAtMs = Math.max(
73
+ ...entries.map((entry) => (Number.isFinite(entry.updatedAtMs) ? (entry.updatedAtMs ?? 0) : 0)),
74
+ );
75
+
76
+ return latestUpdatedAtMs > 0
77
+ ? `${projectName}-${formatBatchExportDate(latestUpdatedAtMs)}-threads-${entries.length}`
78
+ : `${projectName}-threads-${entries.length}`;
79
+ };
80
+
21
81
  const readPipeText = async (pipe: ReadableStream<Uint8Array> | number | undefined) => {
22
82
  return pipe && typeof pipe !== 'number' ? new Response(pipe).text() : '';
23
83
  };