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
@@ -27,9 +27,23 @@ const findClosingBackticks = (text: string, start: number, runLength: number): n
27
27
  };
28
28
 
29
29
  const findThinkCloseIndex = (text: string, start: number): number => {
30
- THINK_CLOSE_PATTERN.lastIndex = start;
31
- const match = THINK_CLOSE_PATTERN.exec(text);
32
- return match ? match.index : -1;
30
+ let cursor = start;
31
+ while (cursor < text.length) {
32
+ THINK_CLOSE_PATTERN.lastIndex = cursor;
33
+ const match = THINK_CLOSE_PATTERN.exec(text);
34
+ if (!match) {
35
+ return -1;
36
+ }
37
+
38
+ const backtickIndex = text.indexOf('`', cursor);
39
+ if (backtickIndex === -1 || backtickIndex >= match.index) {
40
+ return match.index;
41
+ }
42
+
43
+ cursor = findClosingBackticks(text, backtickIndex, getBacktickRunLength(text, backtickIndex));
44
+ }
45
+
46
+ return -1;
33
47
  };
34
48
 
35
49
  const getLastVisibleChar = (parts: string[]): string => {
@@ -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);
@@ -0,0 +1,9 @@
1
+ export const getPortablePathBasename = (value: string): string => {
2
+ const trimmed = value.replace(/[\\/]+$/u, '');
3
+ if (!trimmed) {
4
+ return '';
5
+ }
6
+
7
+ const separatorIndex = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
8
+ return separatorIndex === -1 ? trimmed : trimmed.slice(separatorIndex + 1);
9
+ };
@@ -9,6 +9,7 @@ const INITIALIZE_REQUEST_ID = 1;
9
9
  const LOAD_REQUEST_ID = 2;
10
10
 
11
11
  type JsonRpcMessage = {
12
+ error?: JsonValue;
12
13
  id?: number;
13
14
  jsonrpc?: '2.0';
14
15
  method?: string;
@@ -16,6 +17,14 @@ type JsonRpcMessage = {
16
17
  result?: JsonValue;
17
18
  };
18
19
 
20
+ export const isQoderAcpResponse = (message: JsonRpcMessage, requestId: number) => {
21
+ return (
22
+ message.id === requestId &&
23
+ message.method === undefined &&
24
+ (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))
25
+ );
26
+ };
27
+
19
28
  export type QoderAcpSessionUpdate = {
20
29
  requestId: string | null;
21
30
  sessionId: string;
@@ -207,7 +216,7 @@ export const loadQoderAcpSession = async (
207
216
  };
208
217
 
209
218
  const markLoadCompleted = (message: JsonRpcMessage) => {
210
- if (message.id !== LOAD_REQUEST_ID || loadCompleted) {
219
+ if (!isQoderAcpResponse(message, LOAD_REQUEST_ID) || loadCompleted) {
211
220
  return false;
212
221
  }
213
222
 
@@ -221,7 +230,7 @@ export const loadQoderAcpSession = async (
221
230
  };
222
231
 
223
232
  const handleMessage = (message: JsonRpcMessage) => {
224
- if (message.id === INITIALIZE_REQUEST_ID) {
233
+ if (isQoderAcpResponse(message, INITIALIZE_REQUEST_ID)) {
225
234
  sendLoadRequest();
226
235
  return;
227
236
  }
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { mapWithConcurrency } from './concurrency';
5
+ import { getPortablePathBasename } from './portable-path';
5
6
  import { loadQoderAcpSession, type QoderAcpSessionUpdate, resolveQoderAcpSocketPath } from './qoder-acp-client';
6
7
  import {
7
8
  getDefaultQoderUserDir,
@@ -14,16 +15,18 @@ import {
14
15
  resolveQoderGlobalStateDb,
15
16
  resolveQoderWorkspaceStorageDir,
16
17
  } from './qoder-exporter-types';
18
+ import { coalesceQoderMessageChunks } from './qoder-transcript-phase';
17
19
  import {
18
20
  asObject,
19
21
  asString,
20
22
  cleanExtractedText,
21
23
  cleanInlineTitle,
22
- getPortablePathBasename,
23
24
  isWorkspacePathQuery,
24
25
  type JsonValue,
26
+ toFileUri,
25
27
  workspacePathMatchesQuery,
26
28
  } from './shared';
29
+ import { runWithSqliteRetry } from './sqlite-retry';
27
30
 
28
31
  export {
29
32
  getDefaultQoderUserDir,
@@ -202,7 +205,7 @@ const getWorkspaceLabel = (worktree: string): string => {
202
205
  };
203
206
 
204
207
  const getWorkspaceUri = (worktree: string): string => {
205
- return worktree.startsWith(path.sep) ? `file://${worktree}` : worktree;
208
+ return worktree.startsWith(path.sep) ? toFileUri(worktree) : worktree;
206
209
  };
207
210
 
208
211
  const parseJsonValue = (value: string): JsonValue | null => {
@@ -213,23 +216,50 @@ const parseJsonValue = (value: string): JsonValue | null => {
213
216
  }
214
217
  };
215
218
 
219
+ export const isUnavailableQoderGlobalStateError = (error: unknown): boolean => {
220
+ if (!(error instanceof Error)) {
221
+ return false;
222
+ }
223
+
224
+ const code = 'code' in error && typeof error.code === 'string' ? error.code : '';
225
+ return (
226
+ code === 'SQLITE_CANTOPEN' ||
227
+ code === 'SQLITE_IOERR_READ' ||
228
+ /^(?:SQLite operation failed after \d+ attempts?:\s*)?(?:SQLITE_CANTOPEN:.*|unable to open database file)$/iu.test(
229
+ error.message,
230
+ )
231
+ );
232
+ };
233
+
216
234
  const readGlobalRows = async (globalStateDb = resolveQoderGlobalStateDb()): Promise<ItemTableRow[]> => {
217
- if (!(await pathExists(globalStateDb))) {
235
+ const isDatabaseFile = await stat(globalStateDb)
236
+ .then((metadata) => metadata.isFile())
237
+ .catch(() => false);
238
+ if (!isDatabaseFile) {
218
239
  return [];
219
240
  }
220
241
 
221
- let db: Database | null = null;
222
242
  try {
223
- db = new Database(globalStateDb, { readonly: true, strict: true });
224
- return db
225
- .query(
226
- "select key, value from ItemTable where key like 'lingma.chat.localHistory.%.quest' or key = 'aicoding.questTaskListSnapshot' or key in ('aicoding.modelConfigs.cache.assistant', 'aicoding.modelConfigs.cache.quest')",
227
- )
228
- .all() as ItemTableRow[];
229
- } catch {
230
- return [];
231
- } finally {
232
- db?.close();
243
+ return runWithSqliteRetry({
244
+ action: () => {
245
+ const db = new Database(globalStateDb, { readonly: true, strict: true });
246
+ try {
247
+ db.exec('PRAGMA busy_timeout = 1000');
248
+ return db
249
+ .query(
250
+ "select key, value from ItemTable where key like 'lingma.chat.localHistory.%.quest' or key = 'aicoding.questTaskListSnapshot' or key in ('aicoding.modelConfigs.cache.assistant', 'aicoding.modelConfigs.cache.quest')",
251
+ )
252
+ .all() as ItemTableRow[];
253
+ } finally {
254
+ db.close();
255
+ }
256
+ },
257
+ });
258
+ } catch (error) {
259
+ if (isUnavailableQoderGlobalStateError(error)) {
260
+ return [];
261
+ }
262
+ throw error;
233
263
  }
234
264
  };
235
265
 
@@ -800,6 +830,7 @@ const readRecordSummary = async (
800
830
  modelFallback: string | null,
801
831
  ): Promise<QoderSessionSummary> => {
802
832
  const state = await readStateData(workspaceStorageDir, workspaceStorageIds, record);
833
+ // List stats describe persisted Qoder state only; detail reads may additionally hydrate CLI or live ACP messages.
803
834
  const entries = buildLocalTranscriptEntries(record, state);
804
835
  const stats = createStatsFromEntries(entries, state.snapshotFileCount);
805
836
  return toSessionSummary(record, state, stats, modelFallback);
@@ -1128,6 +1159,7 @@ const cliToolCallPartToTranscriptPart = (part: Record<string, JsonValue>): Qoder
1128
1159
  raw: {
1129
1160
  ...part,
1130
1161
  command: text,
1162
+ toolCallId: asString(data.id ?? data.tool_use_id ?? part.id ?? part.tool_use_id ?? null),
1131
1163
  toolName: getCliToolName(part, data),
1132
1164
  },
1133
1165
  role: 'tool',
@@ -1494,9 +1526,11 @@ const readAcpTranscriptEntries = async (
1494
1526
  }
1495
1527
 
1496
1528
  return {
1497
- entries: loaded.events
1498
- .map((event, index) => acpUpdateToEntry(event, index))
1499
- .filter((entry): entry is QoderTranscriptEntry => Boolean(entry)),
1529
+ entries: coalesceQoderMessageChunks(
1530
+ loaded.events
1531
+ .map((event, index) => acpUpdateToEntry(event, index))
1532
+ .filter((entry): entry is QoderTranscriptEntry => Boolean(entry)),
1533
+ ),
1500
1534
  model: getAcpModel(loaded.events),
1501
1535
  socketPath: loaded.socketPath,
1502
1536
  };
@@ -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 => {
@@ -86,6 +106,18 @@ export const cleanExtractedText = (text: string): string => {
86
106
  return text.replace(/^\s*<\/?image>\s*$/gm, '').replace(/\n{3,}/g, '\n\n');
87
107
  };
88
108
 
109
+ const CODEX_APP_DIRECTIVE_PATTERN =
110
+ /^::(?:code-comment|created-thread|git-commit|git-create-branch|git-create-pr|git-push|git-stage)\{.*\}\s*$/u;
111
+
112
+ export const stripCodexAppDirectiveLines = (text: string): string => {
113
+ return text
114
+ .split('\n')
115
+ .filter((line) => !CODEX_APP_DIRECTIVE_PATTERN.test(line.trim()))
116
+ .join('\n')
117
+ .replace(/\n{3,}/g, '\n\n')
118
+ .trim();
119
+ };
120
+
89
121
  export const formatModelLabel = formatSharedModelLabel;
90
122
 
91
123
  export const asObject = (value: JsonValue): Record<string, JsonValue> | null => {
@@ -116,6 +148,7 @@ export const readJsonlObjects = (filePath: string): AsyncIterableIterator<Record
116
148
  });
117
149
  const lineIterator = lines[Symbol.asyncIterator]();
118
150
  let closed = false;
151
+ let lineNumber = 0;
119
152
 
120
153
  const close = () => {
121
154
  if (closed) {
@@ -135,17 +168,27 @@ export const readJsonlObjects = (filePath: string): AsyncIterableIterator<Record
135
168
  return { done: true, value: undefined as never };
136
169
  }
137
170
 
171
+ lineNumber += 1;
138
172
  const trimmed = nextLine.value.trim();
139
173
  if (!trimmed) {
140
174
  continue;
141
175
  }
142
176
 
143
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
+
144
185
  return {
145
186
  done: false,
146
- value: JSON.parse(trimmed) as Record<string, JsonValue>,
187
+ value: object,
147
188
  };
148
- } catch {}
189
+ } catch {
190
+ console.warn('[spiracha:jsonl] invalid_json_line', { filePath, lineNumber });
191
+ }
149
192
  }
150
193
  };
151
194
 
@@ -213,7 +256,8 @@ export const renderSection = (title: string, body: string, format: ExportFormat)
213
256
 
214
257
  export const renderCodeBlock = (text: string, format: ExportFormat): string => {
215
258
  if (format === 'md') {
216
- return `\`\`\`text\n${text}\n\`\`\``;
259
+ const fence = getBacktickFence(text, 3);
260
+ return `${fence}text\n${text}\n${fence}`;
217
261
  }
218
262
 
219
263
  return text;
@@ -223,10 +267,14 @@ export const formatInlineLiteral = (value: string, format: ExportFormat): string
223
267
  return format === 'md' ? inlineCode(value) : value;
224
268
  };
225
269
 
226
- export const inlineCode = (value: string): string => {
227
- const backtickRuns = value.match(/`+/g) ?? [];
270
+ const getBacktickFence = (value: string, minimumLength: number): string => {
271
+ const backtickRuns = value.match(/`+/gu) ?? [];
228
272
  const maxRunLength = backtickRuns.reduce((max, run) => Math.max(max, run.length), 0);
229
- 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);
230
278
  const padded = value.startsWith('`') || value.endsWith('`') ? ` ${value} ` : value;
231
279
  return `${fence}${padded}${fence}`;
232
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
  };
@@ -0,0 +1,87 @@
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
+ let transcriptLoadLimiter: ReturnType<typeof createConcurrencyLimiter> | null = null;
23
+
24
+ const getTranscriptLoadLimiter = () => {
25
+ transcriptLoadLimiter ??= createConcurrencyLimiter(resolveTranscriptLoadConcurrency());
26
+ return transcriptLoadLimiter;
27
+ };
28
+
29
+ const shouldLogTranscriptLoads = () => process.env.SPIRACHA_TRANSCRIPT_LOAD_LOGS === '1';
30
+
31
+ const logTranscriptLoad = (event: string, details: Record<string, unknown>) => {
32
+ if (shouldLogTranscriptLoads()) {
33
+ console.info(`[spiracha:transcript-load] ${event}`, details);
34
+ }
35
+ };
36
+
37
+ export const runWithTranscriptLoadLimit = async <T>(
38
+ loader: () => Promise<T>,
39
+ context: TranscriptLoadLogContext = {},
40
+ ): Promise<T> => {
41
+ const loadId = nextTranscriptLoadId;
42
+ nextTranscriptLoadId += 1;
43
+ queuedTranscriptLoads += 1;
44
+ const queuedAt = Date.now();
45
+
46
+ return getTranscriptLoadLimiter()(async () => {
47
+ queuedTranscriptLoads -= 1;
48
+ activeTranscriptLoads += 1;
49
+ const startedAt = Date.now();
50
+ logTranscriptLoad('start', {
51
+ active: activeTranscriptLoads,
52
+ id: context.id,
53
+ loadId,
54
+ path: context.path,
55
+ queued: queuedTranscriptLoads,
56
+ source: context.source,
57
+ waitMs: startedAt - queuedAt,
58
+ });
59
+
60
+ try {
61
+ return await loader();
62
+ } catch (error) {
63
+ logTranscriptLoad('error', {
64
+ active: activeTranscriptLoads,
65
+ durationMs: Date.now() - startedAt,
66
+ error: error instanceof Error ? error.message : String(error),
67
+ id: context.id,
68
+ loadId,
69
+ path: context.path,
70
+ queued: queuedTranscriptLoads,
71
+ source: context.source,
72
+ });
73
+ throw error;
74
+ } finally {
75
+ activeTranscriptLoads -= 1;
76
+ logTranscriptLoad('finish', {
77
+ active: activeTranscriptLoads,
78
+ durationMs: Date.now() - startedAt,
79
+ id: context.id,
80
+ loadId,
81
+ path: context.path,
82
+ queued: queuedTranscriptLoads,
83
+ source: context.source,
84
+ });
85
+ }
86
+ });
87
+ };