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.
Files changed (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. 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' ? renderTextPart(part, 'Tool call', options) : '';
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' ? renderTextPart(part, 'Tool output', options) : '';
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(os.homedir(), value.slice(2));
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 getPortablePathBasename = (value: string): string => {
37
- const trimmed = value.replace(/[\\/]+$/u, '');
38
- if (!trimmed) {
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: JSON.parse(trimmed) as Record<string, JsonValue>,
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
- return `\`\`\`text\n${text}\n\`\`\``;
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
- export const inlineCode = (value: string): string => {
239
- const backtickRuns = value.match(/`+/g) ?? [];
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
- const fence = '`'.repeat(maxRunLength + 1);
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
  };
@@ -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
- /unable to open database file/iu,
3
- /database is locked/iu,
4
- /SQLITE_BUSY/iu,
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
- return SQLITE_RETRYABLE_PATTERNS.some((pattern) => pattern.test(error.message));
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
- const transcriptLoadLimiter = createConcurrencyLimiter(resolveTranscriptLoadConcurrency());
22
+ let transcriptLoadLimiter: ReturnType<typeof createConcurrencyLimiter> | null = null;
23
23
 
24
- const shouldLogTranscriptLoads = () => process.env.SPIRACHA_TRANSCRIPT_LOAD_LOGS !== '0';
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 transcriptLoadLimiter(async () => {
46
+ return getTranscriptLoadLimiter()(async () => {
42
47
  queuedTranscriptLoads -= 1;
43
48
  activeTranscriptLoads += 1;
44
49
  const startedAt = Date.now();
@@ -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 ensureCacheDir = async () => {
19
- await mkdir(CACHE_DIR, { mode: 0o700, recursive: true });
20
- await chmod(CACHE_DIR, 0o700);
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 toCachePath = (key: string) => {
24
- const safeKey = key.replace(/[^a-zA-Z0-9._-]/gu, '_');
25
- return path.join(CACHE_DIR, `${safeKey}-${hashCacheKeyParts(key)}.json`);
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
- export const hashCacheKeyParts = (...parts: string[]) => {
29
- return createHash('sha1').update(parts.join('|')).digest('hex');
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
- await setCachedJson(key, value);
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.delete(key);
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) => rm(path.join(CACHE_DIR, entry), { force: true })),
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 count = (usedCounts.get(baseName) ?? 0) + 1;
36
- usedCounts.set(baseName, count);
37
- return count === 1 ? baseName : `${baseName}-${count}`;
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
- };