spiracha 2.1.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 +37 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +17 -10
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
- package/apps/ui/src/components/app-shell.tsx +45 -3
- package/apps/ui/src/components/data-table.tsx +17 -1
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +77 -62
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -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 +4 -1
- package/apps/ui/src/lib/antigravity-server.ts +70 -17
- package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
- package/apps/ui/src/lib/claude-code-server.ts +14 -4
- package/apps/ui/src/lib/codex-queries.ts +3 -3
- package/apps/ui/src/lib/codex-server.ts +3 -3
- package/apps/ui/src/lib/cursor-server.ts +41 -69
- 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/grok-server.ts +2 -3
- package/apps/ui/src/lib/kiro-server.ts +14 -2
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +13 -2
- 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-transcript-events.ts +1 -1
- 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 +1 -1
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
- package/apps/ui/src/routeTree.gen.ts +282 -282
- 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 +73 -19
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
- 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 +29 -16
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
- package/apps/ui/src/routes/grok.index.tsx +2 -2
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
- package/apps/ui/src/routes/opencode.index.tsx +8 -17
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +26 -17
- package/src/client.ts +61 -16
- package/src/lib/antigravity-db.ts +236 -82
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +45 -19
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +2 -7
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +88 -33
- package/src/lib/codex-thread-parser.ts +78 -35
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-renderer.ts +49 -61
- package/src/lib/concurrency.ts +15 -3
- package/src/lib/conversation-api.ts +34 -31
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
- package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
- package/src/lib/conversation-data/codex-adapter.ts +91 -80
- package/src/lib/conversation-data/cursor-adapter.ts +48 -34
- package/src/lib/conversation-data/grok-adapter.ts +19 -6
- package/src/lib/conversation-data/index.ts +29 -10
- package/src/lib/conversation-data/kiro-adapter.ts +18 -5
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +43 -15
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +23 -18
- package/src/lib/conversation-zip-export.ts +20 -2
- package/src/lib/cursor-db.ts +34 -23
- 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 +76 -34
- package/src/lib/grok-transcript-phase.ts +11 -2
- package/src/lib/grok-transcript.ts +15 -2
- package/src/lib/kiro-db.ts +161 -68
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/opencode-db.ts +4 -8
- package/src/lib/opencode-think-tags.ts +17 -3
- 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 +51 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +8 -3
- package/src/lib/ui-cache.ts +152 -17
- package/src/lib/ui-export-archive.ts +14 -55
- 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
|
@@ -2,6 +2,59 @@ import type { QoderTranscriptEntry } from './qoder-exporter-types';
|
|
|
2
2
|
|
|
3
3
|
export type QoderMessagePhase = 'commentary' | 'final_answer' | null;
|
|
4
4
|
|
|
5
|
+
const getMessageChunkType = (entry: QoderTranscriptEntry): string | null => {
|
|
6
|
+
if (entry.entryType !== 'message' || (entry.role !== 'assistant' && entry.role !== 'user')) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const sessionUpdate = entry.parts[0]?.raw.sessionUpdate;
|
|
11
|
+
return typeof sessionUpdate === 'string' && sessionUpdate.endsWith('_message_chunk') ? sessionUpdate : null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const mergeMessageChunks = (first: QoderTranscriptEntry, second: QoderTranscriptEntry): QoderTranscriptEntry => {
|
|
15
|
+
const firstPart = first.parts[0]!;
|
|
16
|
+
const secondPart = second.parts[0]!;
|
|
17
|
+
return {
|
|
18
|
+
...first,
|
|
19
|
+
parts: [
|
|
20
|
+
{
|
|
21
|
+
...firstPart,
|
|
22
|
+
raw: {
|
|
23
|
+
...firstPart.raw,
|
|
24
|
+
coalescedChunkCount:
|
|
25
|
+
(typeof firstPart.raw.coalescedChunkCount === 'number'
|
|
26
|
+
? firstPart.raw.coalescedChunkCount
|
|
27
|
+
: 1) + 1,
|
|
28
|
+
},
|
|
29
|
+
text: `${firstPart.text ?? ''}${secondPart.text ?? ''}`,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
raw: second.raw,
|
|
33
|
+
timestamp: second.timestamp ?? first.timestamp,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const coalesceQoderMessageChunks = (entries: QoderTranscriptEntry[]): QoderTranscriptEntry[] => {
|
|
38
|
+
const coalesced: QoderTranscriptEntry[] = [];
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const previous = coalesced.at(-1);
|
|
41
|
+
const chunkType = getMessageChunkType(entry);
|
|
42
|
+
if (
|
|
43
|
+
previous &&
|
|
44
|
+
chunkType &&
|
|
45
|
+
chunkType === getMessageChunkType(previous) &&
|
|
46
|
+
previous.requestId === entry.requestId &&
|
|
47
|
+
previous.parts.length === 1 &&
|
|
48
|
+
entry.parts.length === 1
|
|
49
|
+
) {
|
|
50
|
+
coalesced[coalesced.length - 1] = mergeMessageChunks(previous, entry);
|
|
51
|
+
} else {
|
|
52
|
+
coalesced.push(entry);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return coalesced;
|
|
56
|
+
};
|
|
57
|
+
|
|
5
58
|
export const getFinalQoderAssistantMessageEntryIds = (entries: QoderTranscriptEntry[]): Set<string> => {
|
|
6
59
|
const finalEntryIds = new Set<string>();
|
|
7
60
|
let latestAssistantMessageEntryId: string | null = null;
|
|
@@ -37,5 +90,16 @@ export const getQoderMessagePhase = (entry: QoderTranscriptEntry, finalAssistant
|
|
|
37
90
|
return null;
|
|
38
91
|
}
|
|
39
92
|
|
|
93
|
+
const isExplicitReasoning = entry.parts.some((part) => {
|
|
94
|
+
return (
|
|
95
|
+
part.raw.sourceType === 'reasoning' ||
|
|
96
|
+
part.raw.sourceType === 'thinking' ||
|
|
97
|
+
part.raw.sessionUpdate === 'agent_thought_chunk'
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
if (isExplicitReasoning) {
|
|
101
|
+
return 'commentary';
|
|
102
|
+
}
|
|
103
|
+
|
|
40
104
|
return finalAssistantMessageEntryIds.has(entry.entryId) ? 'final_answer' : 'commentary';
|
|
41
105
|
};
|
|
@@ -9,6 +9,7 @@ import { getFinalQoderAssistantMessageEntryIds, getQoderMessagePhase } from './q
|
|
|
9
9
|
import {
|
|
10
10
|
cleanExtractedText,
|
|
11
11
|
cleanInlineTitle,
|
|
12
|
+
formatInlineLiteral,
|
|
12
13
|
type MetadataEntry,
|
|
13
14
|
renderDocumentTitle,
|
|
14
15
|
renderMetadataBlock,
|
|
@@ -61,6 +62,33 @@ const renderTextPart = (part: QoderTranscriptPart, title: string, options: Qoder
|
|
|
61
62
|
return text ? renderSection(title, text, options.outputFormat) : '';
|
|
62
63
|
};
|
|
63
64
|
|
|
65
|
+
const getPartString = (part: QoderTranscriptPart, key: string): string | null => {
|
|
66
|
+
const value = part.raw[key];
|
|
67
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const renderToolPart = (part: QoderTranscriptPart, title: string, options: QoderExportOptions): string => {
|
|
71
|
+
const text = cleanExtractedText(part.text ?? '').trim();
|
|
72
|
+
if (!text) {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
const toolName = getPartString(part, 'toolName');
|
|
78
|
+
const toolCallId = getPartString(part, 'toolCallId');
|
|
79
|
+
if (toolName) {
|
|
80
|
+
lines.push(`Tool: ${formatInlineLiteral(toolName, options.outputFormat)}`);
|
|
81
|
+
}
|
|
82
|
+
if (toolCallId) {
|
|
83
|
+
lines.push(`Call ID: ${toolCallId}`);
|
|
84
|
+
}
|
|
85
|
+
if (lines.length > 0) {
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
lines.push(text);
|
|
89
|
+
return renderSection(title, lines.join('\n'), options.outputFormat);
|
|
90
|
+
};
|
|
91
|
+
|
|
64
92
|
const renderPart = (
|
|
65
93
|
entry: QoderTranscriptEntry,
|
|
66
94
|
part: QoderTranscriptPart,
|
|
@@ -68,11 +96,11 @@ const renderPart = (
|
|
|
68
96
|
finalAssistantMessageEntryIds: Set<string>,
|
|
69
97
|
): string => {
|
|
70
98
|
if (entry.entryType === 'tool_call') {
|
|
71
|
-
return options.includeTools && part.type === 'text' ?
|
|
99
|
+
return options.includeTools && part.type === 'text' ? renderToolPart(part, 'Tool call', options) : '';
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
if (entry.entryType === 'tool_output') {
|
|
75
|
-
return options.includeTools && part.type === 'text' ?
|
|
103
|
+
return options.includeTools && part.type === 'text' ? renderToolPart(part, 'Tool output', options) : '';
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
if (getQoderMessagePhase(entry, finalAssistantMessageEntryIds) === 'commentary' && !options.includeCommentary) {
|
package/src/lib/shared.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
-
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readdir, stat } 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
6
|
import { finished } from 'node:stream/promises';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
7
8
|
import { formatModelLabel as formatSharedModelLabel } from './model-label';
|
|
8
9
|
|
|
9
10
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
@@ -17,6 +18,19 @@ export type MetadataEntry = {
|
|
|
17
18
|
|
|
18
19
|
export class CliUsageError extends Error {}
|
|
19
20
|
|
|
21
|
+
export const readDirectoryEntriesIfExists = async (directoryPath: string) => {
|
|
22
|
+
try {
|
|
23
|
+
return await readdir(directoryPath, { withFileTypes: true });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if ((error as { code?: unknown }).code === 'ENOENT') {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const toFileUri = (filePath: string): string => pathToFileURL(filePath).href;
|
|
33
|
+
|
|
20
34
|
export const expandHome = (value: string): string => {
|
|
21
35
|
if (!value) {
|
|
22
36
|
return value;
|
|
@@ -26,20 +40,26 @@ export const expandHome = (value: string): string => {
|
|
|
26
40
|
return os.homedir();
|
|
27
41
|
}
|
|
28
42
|
|
|
29
|
-
if (value.startsWith('~/')) {
|
|
30
|
-
return path.join(
|
|
43
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) {
|
|
44
|
+
return path.join(
|
|
45
|
+
os.homedir(),
|
|
46
|
+
...value
|
|
47
|
+
.slice(2)
|
|
48
|
+
.split(/[\\/]+/u)
|
|
49
|
+
.filter(Boolean),
|
|
50
|
+
);
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
return value;
|
|
34
54
|
};
|
|
35
55
|
|
|
36
|
-
export const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
56
|
+
export const pathExists = async (target: string): Promise<boolean> => {
|
|
57
|
+
try {
|
|
58
|
+
await stat(target);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
40
62
|
}
|
|
41
|
-
|
|
42
|
-
return path.win32.basename(path.posix.basename(trimmed));
|
|
43
63
|
};
|
|
44
64
|
|
|
45
65
|
export const isWorkspacePathQuery = (value: string): boolean => {
|
|
@@ -128,6 +148,7 @@ export const readJsonlObjects = (filePath: string): AsyncIterableIterator<Record
|
|
|
128
148
|
});
|
|
129
149
|
const lineIterator = lines[Symbol.asyncIterator]();
|
|
130
150
|
let closed = false;
|
|
151
|
+
let lineNumber = 0;
|
|
131
152
|
|
|
132
153
|
const close = () => {
|
|
133
154
|
if (closed) {
|
|
@@ -147,17 +168,27 @@ export const readJsonlObjects = (filePath: string): AsyncIterableIterator<Record
|
|
|
147
168
|
return { done: true, value: undefined as never };
|
|
148
169
|
}
|
|
149
170
|
|
|
171
|
+
lineNumber += 1;
|
|
150
172
|
const trimmed = nextLine.value.trim();
|
|
151
173
|
if (!trimmed) {
|
|
152
174
|
continue;
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
try {
|
|
178
|
+
const parsed = JSON.parse(trimmed) as JsonValue;
|
|
179
|
+
const object = asObject(parsed);
|
|
180
|
+
if (!object) {
|
|
181
|
+
console.warn('[spiracha:jsonl] invalid_json_line', { filePath, lineNumber });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
156
185
|
return {
|
|
157
186
|
done: false,
|
|
158
|
-
value:
|
|
187
|
+
value: object,
|
|
159
188
|
};
|
|
160
|
-
} catch {
|
|
189
|
+
} catch {
|
|
190
|
+
console.warn('[spiracha:jsonl] invalid_json_line', { filePath, lineNumber });
|
|
191
|
+
}
|
|
161
192
|
}
|
|
162
193
|
};
|
|
163
194
|
|
|
@@ -225,7 +256,8 @@ export const renderSection = (title: string, body: string, format: ExportFormat)
|
|
|
225
256
|
|
|
226
257
|
export const renderCodeBlock = (text: string, format: ExportFormat): string => {
|
|
227
258
|
if (format === 'md') {
|
|
228
|
-
|
|
259
|
+
const fence = getBacktickFence(text, 3);
|
|
260
|
+
return `${fence}text\n${text}\n${fence}`;
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
return text;
|
|
@@ -235,10 +267,14 @@ export const formatInlineLiteral = (value: string, format: ExportFormat): string
|
|
|
235
267
|
return format === 'md' ? inlineCode(value) : value;
|
|
236
268
|
};
|
|
237
269
|
|
|
238
|
-
|
|
239
|
-
const backtickRuns = value.match(/`+/
|
|
270
|
+
const getBacktickFence = (value: string, minimumLength: number): string => {
|
|
271
|
+
const backtickRuns = value.match(/`+/gu) ?? [];
|
|
240
272
|
const maxRunLength = backtickRuns.reduce((max, run) => Math.max(max, run.length), 0);
|
|
241
|
-
|
|
273
|
+
return '`'.repeat(Math.max(minimumLength, maxRunLength + 1));
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const inlineCode = (value: string): string => {
|
|
277
|
+
const fence = getBacktickFence(value, 1);
|
|
242
278
|
const padded = value.startsWith('`') || value.endsWith('`') ? ` ${value} ` : value;
|
|
243
279
|
return `${fence}${padded}${fence}`;
|
|
244
280
|
};
|
package/src/lib/sqlite-error.ts
CHANGED
|
@@ -1,14 +1,40 @@
|
|
|
1
|
+
const SQLITE_RETRYABLE_CODES = new Set(['SQLITE_BUSY', 'SQLITE_BUSY_SNAPSHOT', 'SQLITE_CANTOPEN', 'SQLITE_LOCKED']);
|
|
2
|
+
const SQLITE_OPERATION_PREFIX = String.raw`(?:SQLite operation failed after \d+ attempts?:\s*)?`;
|
|
1
3
|
const SQLITE_RETRYABLE_PATTERNS = [
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/
|
|
5
|
-
/SQLITE_CANTOPEN/iu,
|
|
4
|
+
new RegExp(`^${SQLITE_OPERATION_PREFIX}unable to open database(?: file)?(?:$|:)`, 'iu'),
|
|
5
|
+
new RegExp(`^${SQLITE_OPERATION_PREFIX}database(?: table)? is locked(?:$|:)`, 'iu'),
|
|
6
|
+
/^SQLITE_(?:BUSY|CANTOPEN|LOCKED)(?:_[A-Z_]+)?(?:$|:)/u,
|
|
6
7
|
];
|
|
8
|
+
const SQLITE_DATABASE_PATTERNS = [
|
|
9
|
+
...SQLITE_RETRYABLE_PATTERNS,
|
|
10
|
+
/^SQLITE_[A-Z_]+(?:$|:)/u,
|
|
11
|
+
new RegExp(
|
|
12
|
+
`^${SQLITE_OPERATION_PREFIX}(?:attempt to write a readonly database|database disk image is malformed|disk I/O error)(?:$|:)`,
|
|
13
|
+
'iu',
|
|
14
|
+
),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const getSqliteCode = (error: Error): string | null => {
|
|
18
|
+
const code = 'code' in error ? error.code : null;
|
|
19
|
+
return typeof code === 'string' && code.startsWith('SQLITE_') ? code.toUpperCase() : null;
|
|
20
|
+
};
|
|
7
21
|
|
|
8
22
|
export const isRetryableSqliteError = (error: unknown) => {
|
|
9
23
|
if (!(error instanceof Error)) {
|
|
10
24
|
return false;
|
|
11
25
|
}
|
|
12
26
|
|
|
13
|
-
|
|
27
|
+
const code = getSqliteCode(error);
|
|
28
|
+
return (
|
|
29
|
+
Boolean(code && SQLITE_RETRYABLE_CODES.has(code)) ||
|
|
30
|
+
SQLITE_RETRYABLE_PATTERNS.some((pattern) => pattern.test(error.message))
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const isSqliteDatabaseError = (error: unknown): boolean => {
|
|
35
|
+
if (!(error instanceof Error)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return getSqliteCode(error) !== null || SQLITE_DATABASE_PATTERNS.some((pattern) => pattern.test(error.message));
|
|
14
40
|
};
|
|
@@ -19,9 +19,14 @@ export const resolveTranscriptLoadConcurrency = (value = process.env.SPIRACHA_TR
|
|
|
19
19
|
: DEFAULT_TRANSCRIPT_LOAD_CONCURRENCY;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
let transcriptLoadLimiter: ReturnType<typeof createConcurrencyLimiter> | null = null;
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const getTranscriptLoadLimiter = () => {
|
|
25
|
+
transcriptLoadLimiter ??= createConcurrencyLimiter(resolveTranscriptLoadConcurrency());
|
|
26
|
+
return transcriptLoadLimiter;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const shouldLogTranscriptLoads = () => process.env.SPIRACHA_TRANSCRIPT_LOAD_LOGS === '1';
|
|
25
30
|
|
|
26
31
|
const logTranscriptLoad = (event: string, details: Record<string, unknown>) => {
|
|
27
32
|
if (shouldLogTranscriptLoads()) {
|
|
@@ -38,7 +43,7 @@ export const runWithTranscriptLoadLimit = async <T>(
|
|
|
38
43
|
queuedTranscriptLoads += 1;
|
|
39
44
|
const queuedAt = Date.now();
|
|
40
45
|
|
|
41
|
-
return
|
|
46
|
+
return getTranscriptLoadLimiter()(async () => {
|
|
42
47
|
queuedTranscriptLoads -= 1;
|
|
43
48
|
activeTranscriptLoads += 1;
|
|
44
49
|
const startedAt = Date.now();
|
package/src/lib/ui-cache.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { chmod, lstat, mkdir, readdir, rename, rm, stat, utimes } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
const CACHE_DIR = path.join(os.tmpdir(), 'spiracha-ui-cache');
|
|
7
7
|
const CACHE_ENVELOPE_VERSION = 1;
|
|
8
|
+
const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_CACHE_MAX_BYTES = 256 * 1024 * 1024;
|
|
10
|
+
const CACHE_PURGE_INTERVAL_MS = 60 * 1000;
|
|
11
|
+
const CACHE_KEY_PREFIX_MAX_LENGTH = 80;
|
|
8
12
|
|
|
9
13
|
type CacheEnvelope<T> = {
|
|
10
14
|
value: T;
|
|
@@ -14,19 +18,135 @@ type CacheEnvelope<T> = {
|
|
|
14
18
|
type CacheReadResult<T> = { hit: true; value: T } | { hit: false };
|
|
15
19
|
|
|
16
20
|
const inFlightCacheLoads = new Map<string, Promise<unknown>>();
|
|
21
|
+
const activeCachePathCounts = new Map<string, number>();
|
|
22
|
+
const pendingCachePathRemovals = new Set<string>();
|
|
23
|
+
let cacheDirectoryInitialized = false;
|
|
24
|
+
let cacheInvalidationGeneration = 0;
|
|
25
|
+
let lastCachePurgeAtMs = 0;
|
|
17
26
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
const removeInactiveCachePath = async (filePath: string): Promise<void> => {
|
|
28
|
+
if (activeCachePathCounts.has(filePath)) {
|
|
29
|
+
pendingCachePathRemovals.add(filePath);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await rm(filePath, { force: true });
|
|
21
33
|
};
|
|
22
34
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
const beginActiveCachePath = (filePath: string): void => {
|
|
36
|
+
activeCachePathCounts.set(filePath, (activeCachePathCounts.get(filePath) ?? 0) + 1);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const finishActiveCachePath = async (filePath: string): Promise<void> => {
|
|
40
|
+
const remainingCount = (activeCachePathCounts.get(filePath) ?? 1) - 1;
|
|
41
|
+
if (remainingCount > 0) {
|
|
42
|
+
activeCachePathCounts.set(filePath, remainingCount);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
activeCachePathCounts.delete(filePath);
|
|
46
|
+
if (pendingCachePathRemovals.delete(filePath)) {
|
|
47
|
+
await rm(filePath, { force: true });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const pruneUiCacheEntries = async (
|
|
52
|
+
cacheDir: string = CACHE_DIR,
|
|
53
|
+
maxAgeMs: number = DEFAULT_CACHE_MAX_AGE_MS,
|
|
54
|
+
maxBytes: number = DEFAULT_CACHE_MAX_BYTES,
|
|
55
|
+
) => {
|
|
56
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
57
|
+
const entries = await readdir(cacheDir, { withFileTypes: true }).catch((error: unknown) => {
|
|
58
|
+
if ((error as { code?: unknown }).code === 'ENOENT') {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
});
|
|
63
|
+
const cacheFiles = (
|
|
64
|
+
await Promise.all(
|
|
65
|
+
entries
|
|
66
|
+
.filter((entry) => entry.isFile())
|
|
67
|
+
.map(async (entry) => {
|
|
68
|
+
const filePath = path.join(cacheDir, entry.name);
|
|
69
|
+
try {
|
|
70
|
+
const metadata = await stat(filePath);
|
|
71
|
+
return { filePath, mtimeMs: metadata.mtimeMs, name: entry.name, size: metadata.size };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if ((error as { code?: unknown }).code === 'ENOENT') {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
|
81
|
+
const staleFiles = cacheFiles.filter((entry) => entry.name.endsWith('.json') && entry.mtimeMs < cutoff);
|
|
82
|
+
await Promise.all(staleFiles.map((entry) => removeInactiveCachePath(entry.filePath)));
|
|
83
|
+
|
|
84
|
+
const retainedCacheFiles = cacheFiles.filter((entry) => entry.mtimeMs >= cutoff && entry.name.endsWith('.json'));
|
|
85
|
+
let retainedBytes = retainedCacheFiles.reduce((total, entry) => total + entry.size, 0);
|
|
86
|
+
if (retainedBytes <= maxBytes) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const retainedOldestFirst = retainedCacheFiles.sort(
|
|
91
|
+
(left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name),
|
|
92
|
+
);
|
|
93
|
+
const oversizedFiles = [];
|
|
94
|
+
for (const entry of retainedOldestFirst) {
|
|
95
|
+
if (retainedBytes <= maxBytes) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
oversizedFiles.push(entry.filePath);
|
|
99
|
+
retainedBytes -= entry.size;
|
|
100
|
+
}
|
|
101
|
+
await Promise.all(oversizedFiles.map(removeInactiveCachePath));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const assertSafeCacheDirectory = async () => {
|
|
105
|
+
const metadata = await lstat(CACHE_DIR);
|
|
106
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
107
|
+
throw new Error(`Unsafe Spiracha cache directory: ${CACHE_DIR}`);
|
|
108
|
+
}
|
|
109
|
+
return metadata;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const refreshCacheDirectoryState = async () => {
|
|
113
|
+
try {
|
|
114
|
+
await assertSafeCacheDirectory();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if ((error as { code?: unknown }).code !== 'ENOENT') {
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
cacheDirectoryInitialized = false;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const ensureCacheDir = async () => {
|
|
124
|
+
if (cacheDirectoryInitialized) {
|
|
125
|
+
await refreshCacheDirectoryState();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!cacheDirectoryInitialized) {
|
|
129
|
+
await mkdir(CACHE_DIR, { mode: 0o700, recursive: true });
|
|
130
|
+
const metadata = await assertSafeCacheDirectory();
|
|
131
|
+
if ((metadata.mode & 0o777) !== 0o700) {
|
|
132
|
+
await chmod(CACHE_DIR, 0o700);
|
|
133
|
+
}
|
|
134
|
+
cacheDirectoryInitialized = true;
|
|
135
|
+
}
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
if (now - lastCachePurgeAtMs >= CACHE_PURGE_INTERVAL_MS) {
|
|
138
|
+
lastCachePurgeAtMs = now;
|
|
139
|
+
void pruneUiCacheEntries(CACHE_DIR).catch((error) => {
|
|
140
|
+
console.warn('[spiracha:ui-cache] cache pruning failed', {
|
|
141
|
+
error: error instanceof Error ? error.message : String(error),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
26
145
|
};
|
|
27
146
|
|
|
28
|
-
|
|
29
|
-
|
|
147
|
+
const toCachePath = (key: string) => {
|
|
148
|
+
const safeKey = key.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(0, CACHE_KEY_PREFIX_MAX_LENGTH);
|
|
149
|
+
return path.join(CACHE_DIR, `${safeKey}-${hashCacheKeyPartsIterable([key])}.json`);
|
|
30
150
|
};
|
|
31
151
|
|
|
32
152
|
export const hashCacheKeyPartsIterable = (parts: Iterable<string>) => {
|
|
@@ -55,11 +175,14 @@ const readCachedJson = async <T>(key: string): Promise<CacheReadResult<T>> => {
|
|
|
55
175
|
}
|
|
56
176
|
|
|
57
177
|
let parsed: CacheEnvelope<T> | T;
|
|
178
|
+
beginActiveCachePath(filePath);
|
|
58
179
|
try {
|
|
59
180
|
parsed = (await file.json()) as CacheEnvelope<T> | T;
|
|
60
181
|
} catch {
|
|
61
182
|
await rm(filePath, { force: true });
|
|
62
183
|
return { hit: false };
|
|
184
|
+
} finally {
|
|
185
|
+
await finishActiveCachePath(filePath);
|
|
63
186
|
}
|
|
64
187
|
|
|
65
188
|
if (
|
|
@@ -69,6 +192,8 @@ const readCachedJson = async <T>(key: string): Promise<CacheReadResult<T>> => {
|
|
|
69
192
|
(parsed as CacheEnvelope<T>).version === CACHE_ENVELOPE_VERSION &&
|
|
70
193
|
'value' in parsed
|
|
71
194
|
) {
|
|
195
|
+
const now = new Date();
|
|
196
|
+
await utimes(filePath, now, now).catch(() => undefined);
|
|
72
197
|
return { hit: true, value: (parsed as CacheEnvelope<T>).value };
|
|
73
198
|
}
|
|
74
199
|
|
|
@@ -90,28 +215,35 @@ export const setCachedJson = async <T>(key: string, value: T) => {
|
|
|
90
215
|
version: CACHE_ENVELOPE_VERSION,
|
|
91
216
|
};
|
|
92
217
|
|
|
218
|
+
beginActiveCachePath(filePath);
|
|
219
|
+
beginActiveCachePath(tempPath);
|
|
93
220
|
try {
|
|
94
221
|
await Bun.write(tempPath, JSON.stringify(envelope));
|
|
95
222
|
await rename(tempPath, filePath);
|
|
96
223
|
} finally {
|
|
97
224
|
await rm(tempPath, { force: true });
|
|
225
|
+
await finishActiveCachePath(tempPath);
|
|
226
|
+
await finishActiveCachePath(filePath);
|
|
98
227
|
}
|
|
99
228
|
};
|
|
100
229
|
|
|
101
230
|
export const withCachedJson = async <T>(key: string, loader: () => Promise<T>): Promise<T> => {
|
|
102
|
-
const cached = await readCachedJson<T>(key);
|
|
103
|
-
if (cached.hit) {
|
|
104
|
-
return cached.value;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
231
|
const inFlight = inFlightCacheLoads.get(key);
|
|
108
232
|
if (inFlight) {
|
|
109
233
|
return (await inFlight) as T;
|
|
110
234
|
}
|
|
111
235
|
|
|
112
236
|
const load = (async () => {
|
|
237
|
+
const generation = cacheInvalidationGeneration;
|
|
238
|
+
const cached = await readCachedJson<T>(key);
|
|
239
|
+
if (cached.hit) {
|
|
240
|
+
return cached.value;
|
|
241
|
+
}
|
|
242
|
+
|
|
113
243
|
const value = await loader();
|
|
114
|
-
|
|
244
|
+
if (generation === cacheInvalidationGeneration) {
|
|
245
|
+
await setCachedJson(key, value);
|
|
246
|
+
}
|
|
115
247
|
return value;
|
|
116
248
|
})();
|
|
117
249
|
inFlightCacheLoads.set(key, load);
|
|
@@ -119,17 +251,20 @@ export const withCachedJson = async <T>(key: string, loader: () => Promise<T>):
|
|
|
119
251
|
try {
|
|
120
252
|
return await load;
|
|
121
253
|
} finally {
|
|
122
|
-
inFlightCacheLoads.
|
|
254
|
+
if (inFlightCacheLoads.get(key) === load) {
|
|
255
|
+
inFlightCacheLoads.delete(key);
|
|
256
|
+
}
|
|
123
257
|
}
|
|
124
258
|
};
|
|
125
259
|
|
|
126
260
|
export const invalidateCacheByPrefix = async (...prefixes: string[]) => {
|
|
261
|
+
cacheInvalidationGeneration += 1;
|
|
127
262
|
await ensureCacheDir();
|
|
128
263
|
const entries = await readdir(CACHE_DIR);
|
|
129
264
|
|
|
130
265
|
await Promise.all(
|
|
131
266
|
entries
|
|
132
267
|
.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)))
|
|
133
|
-
.map((entry) =>
|
|
268
|
+
.map((entry) => removeInactiveCachePath(path.join(CACHE_DIR, entry))),
|
|
134
269
|
);
|
|
135
270
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getPortablePathBasename } from './portable-path';
|
|
1
2
|
import type { ExportFormat } from './shared';
|
|
2
3
|
|
|
3
4
|
type BatchExportNameEntry = {
|
|
@@ -9,16 +10,6 @@ type ConversationExportNameEntry = BatchExportNameEntry & {
|
|
|
9
10
|
id: string;
|
|
10
11
|
};
|
|
11
12
|
|
|
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
|
-
|
|
22
13
|
export const sanitizeExportFileName = (value: string) => {
|
|
23
14
|
return value
|
|
24
15
|
.replace(/[<>:"/\\|?*\u0000-\u001f]/gu, ' ')
|
|
@@ -32,9 +23,19 @@ export const getExportMimeType = (outputFormat: ExportFormat) => {
|
|
|
32
23
|
};
|
|
33
24
|
|
|
34
25
|
export const resolveUniqueExportFileBaseName = (baseName: string, usedCounts: Map<string, number>) => {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
const normalizeKey = (value: string) => value.normalize('NFC').toLowerCase();
|
|
27
|
+
const baseKey = normalizeKey(baseName);
|
|
28
|
+
let count = (usedCounts.get(baseKey) ?? 0) + 1;
|
|
29
|
+
let candidate = count === 1 ? baseName : `${baseName}-${count}`;
|
|
30
|
+
|
|
31
|
+
while (usedCounts.has(normalizeKey(candidate))) {
|
|
32
|
+
count += 1;
|
|
33
|
+
candidate = `${baseName}-${count}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
usedCounts.set(baseKey, count);
|
|
37
|
+
usedCounts.set(normalizeKey(candidate), Math.max(usedCounts.get(normalizeKey(candidate)) ?? 0, 1));
|
|
38
|
+
return candidate;
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
const formatBatchExportDate = (value: number) => {
|
|
@@ -77,45 +78,3 @@ export const buildBatchExportBaseName = (entries: BatchExportNameEntry[], fallba
|
|
|
77
78
|
? `${projectName}-${formatBatchExportDate(latestUpdatedAtMs)}-threads-${entries.length}`
|
|
78
79
|
: `${projectName}-threads-${entries.length}`;
|
|
79
80
|
};
|
|
80
|
-
|
|
81
|
-
const readPipeText = async (pipe: ReadableStream<Uint8Array> | number | undefined) => {
|
|
82
|
-
return pipe && typeof pipe !== 'number' ? new Response(pipe).text() : '';
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const runZip = async (args: string[], options: { cwd?: string } = {}) => {
|
|
86
|
-
let proc: ReturnType<typeof Bun.spawn>;
|
|
87
|
-
try {
|
|
88
|
-
proc = Bun.spawn(['zip', '-9', ...args], {
|
|
89
|
-
cwd: options.cwd,
|
|
90
|
-
stderr: 'pipe',
|
|
91
|
-
stdout: 'pipe',
|
|
92
|
-
});
|
|
93
|
-
} catch (error) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
`zip command failed to start. Install the "zip" executable or choose a non-zip export. ${error instanceof Error ? error.message : String(error)}`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
|
100
|
-
readPipeText(proc.stdout),
|
|
101
|
-
readPipeText(proc.stderr),
|
|
102
|
-
proc.exited,
|
|
103
|
-
]);
|
|
104
|
-
|
|
105
|
-
if (exitCode !== 0) {
|
|
106
|
-
const output = (stderrText || stdoutText).trim();
|
|
107
|
-
throw new Error(
|
|
108
|
-
output
|
|
109
|
-
? `zip command failed (${exitCode}): ${output}`
|
|
110
|
-
: `zip command failed (${exitCode}). Install the "zip" executable or choose a non-zip export.`,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
export const zipExportFile = async (sourcePath: string, zipPath: string) => {
|
|
116
|
-
await runZip(['-j', zipPath, sourcePath]);
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
export const zipExportDirectory = async (sourceDirectory: string, zipPath: string) => {
|
|
120
|
-
await runZip(['-r', zipPath, '.'], { cwd: sourceDirectory });
|
|
121
|
-
};
|