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
@@ -5,6 +5,7 @@ import type {
5
5
  GrokTranscriptEntry,
6
6
  GrokTranscriptPart,
7
7
  } from './grok-exporter-types';
8
+ import { getFinalGrokAssistantTextPartIds, getGrokTextPartPhase } from './grok-transcript-phase';
8
9
  import {
9
10
  cleanExtractedText,
10
11
  cleanInlineTitle,
@@ -118,9 +119,20 @@ const renderToolResultPart = (part: GrokTranscriptPart, options: GrokExportOptio
118
119
  return renderSection('Tool Output', lines.join('\n'), options.outputFormat);
119
120
  };
120
121
 
121
- const renderPart = (entry: GrokTranscriptEntry, part: GrokTranscriptPart, options: GrokExportOptions): string => {
122
+ const renderPart = (
123
+ entry: GrokTranscriptEntry,
124
+ part: GrokTranscriptPart,
125
+ options: GrokExportOptions,
126
+ finalAssistantTextPartIds: Set<string>,
127
+ ): string => {
122
128
  switch (part.type) {
123
129
  case 'text':
130
+ if (
131
+ getGrokTextPartPhase(entry, part, finalAssistantTextPartIds) === 'commentary' &&
132
+ !options.includeCommentary
133
+ ) {
134
+ return '';
135
+ }
124
136
  return renderTextPart(entry, part, options);
125
137
  case 'reasoning':
126
138
  return renderReasoningPart(part, options);
@@ -134,8 +146,9 @@ const renderPart = (entry: GrokTranscriptEntry, part: GrokTranscriptPart, option
134
146
  };
135
147
 
136
148
  export const renderGrokTranscript = (transcript: GrokSessionTranscript, options: GrokExportOptions): string | null => {
149
+ const finalAssistantTextPartIds = getFinalGrokAssistantTextPartIds(transcript.entries);
137
150
  const sections = transcript.entries.flatMap((entry) =>
138
- entry.parts.map((part) => renderPart(entry, part, options)).filter(Boolean),
151
+ entry.parts.map((part) => renderPart(entry, part, options, finalAssistantTextPartIds)).filter(Boolean),
139
152
  );
140
153
  if (sections.length === 0) {
141
154
  return null;
@@ -1,7 +1,7 @@
1
- import { createHash } from 'node:crypto';
2
- import { readdir, rm, stat } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readdir, rename, rm, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { mapWithConcurrency } from './concurrency';
4
+ import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
5
5
  import {
6
6
  getDefaultKiroDataDir,
7
7
  type KiroSessionSummary,
@@ -11,21 +11,26 @@ import {
11
11
  type KiroWorkspaceGroup,
12
12
  resolveKiroWorkspaceSessionsDir,
13
13
  } from './kiro-exporter-types';
14
+ import { getPortablePathBasename } from './portable-path';
14
15
  import {
15
16
  asObject,
16
17
  asString,
17
18
  cleanExtractedText,
18
19
  cleanInlineTitle,
19
- getPortablePathBasename,
20
20
  isWorkspacePathQuery,
21
21
  type JsonValue,
22
+ readDirectoryEntriesIfExists,
22
23
  workspacePathMatchesQuery,
23
24
  } from './shared';
24
25
 
25
26
  export { getDefaultKiroDataDir, resolveKiroWorkspaceSessionsDir };
26
27
 
27
28
  const READ_CONCURRENCY = 8;
29
+ const DELETE_CONCURRENCY = 1;
30
+ const EXECUTION_CACHE_TTL_MS = 1_000;
28
31
  const WORKSPACE_KEY_PREFIX = 'workspace:';
32
+ const kiroDeleteLimiter = createConcurrencyLimiter(DELETE_CONCURRENCY);
33
+ const executionFilesCache = new Map<string, { expiresAtMs: number; files: KiroExecutionFile[] }>();
29
34
 
30
35
  type KiroSessionIndexEntry = {
31
36
  createdAtMs: number | null;
@@ -87,19 +92,23 @@ const toIso = (value: number | null): string | null => {
87
92
  return value === null ? null : new Date(value).toISOString();
88
93
  };
89
94
 
95
+ const parseTimestampStringMs = (value: string): number | null => {
96
+ const numeric = Number(value);
97
+ if (Number.isFinite(numeric)) {
98
+ return numeric > 0 && numeric < 10_000_000_000 ? numeric * 1000 : numeric;
99
+ }
100
+
101
+ const parsed = Date.parse(value);
102
+ return Number.isFinite(parsed) ? parsed : null;
103
+ };
104
+
90
105
  const parseTimestampMs = (value: JsonValue | undefined): number | null => {
91
106
  if (typeof value === 'number' && Number.isFinite(value)) {
92
107
  return value > 0 && value < 10_000_000_000 ? value * 1000 : value;
93
108
  }
94
109
 
95
110
  if (typeof value === 'string') {
96
- const numeric = Number(value);
97
- if (Number.isFinite(numeric)) {
98
- return numeric > 0 && numeric < 10_000_000_000 ? numeric * 1000 : numeric;
99
- }
100
-
101
- const parsed = Date.parse(value);
102
- return Number.isFinite(parsed) ? parsed : null;
111
+ return value.trim() ? parseTimestampStringMs(value) : null;
103
112
  }
104
113
 
105
114
  return null;
@@ -288,7 +297,9 @@ const parseHistoryEntry = (raw: Record<string, JsonValue>, index: number): KiroT
288
297
  promptLogCount: getPromptLogCount(raw),
289
298
  raw,
290
299
  role: asString(message.role ?? null) ?? 'message',
291
- timestamp: null,
300
+ timestamp: toIso(
301
+ parseTimestampMs(raw.timestamp ?? raw.createdAt ?? raw.created_at ?? message.timestamp ?? null),
302
+ ),
292
303
  };
293
304
  };
294
305
 
@@ -444,7 +455,7 @@ const getKiroWorkspaceHash = (workspacePath: string): string => {
444
455
  };
445
456
 
446
457
  const listFilesRecursively = async (root: string, maxDepth = 3): Promise<string[]> => {
447
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
458
+ const entries = await readDirectoryEntriesIfExists(root);
448
459
  const files: string[] = [];
449
460
 
450
461
  for (const entry of entries) {
@@ -472,18 +483,31 @@ const listExecutionFilesForSession = async (
472
483
  return [];
473
484
  }
474
485
 
475
- const files = await listFilesRecursively(workspaceExecutionRoot);
476
- const executions = await mapWithConcurrency(files, READ_CONCURRENCY, async (filePath) => {
477
- const raw = await readJsonObject(filePath);
478
- return asString(raw?.chatSessionId ?? null) === sessionId && raw ? { filePath, raw } : null;
479
- });
486
+ const cached = executionFilesCache.get(workspaceExecutionRoot);
487
+ let executions = cached && cached.expiresAtMs > Date.now() ? cached.files : null;
488
+ if (!executions) {
489
+ const files = await listFilesRecursively(workspaceExecutionRoot);
490
+ executions = (
491
+ await mapWithConcurrency(files, READ_CONCURRENCY, async (filePath) => {
492
+ const raw = await readJsonObject(filePath);
493
+ return raw ? { filePath, raw } : null;
494
+ })
495
+ ).flatMap((execution) => (execution ? [execution] : []));
496
+ executionFilesCache.set(workspaceExecutionRoot, {
497
+ expiresAtMs: Date.now() + EXECUTION_CACHE_TTL_MS,
498
+ files: executions,
499
+ });
500
+ }
480
501
 
481
- return executions.flatMap((execution) => (execution ? [execution] : []));
502
+ return executions.filter((execution) => asString(execution.raw.chatSessionId ?? null) === sessionId);
482
503
  };
483
504
 
484
505
  const getActionTimestamp = (action: Record<string, JsonValue>, execution: Record<string, JsonValue>): string | null => {
485
506
  return toIso(
486
- parseTimestampMs(action.emittedAt) ?? parseTimestampMs(action.endTime) ?? parseTimestampMs(execution.endTime),
507
+ parseTimestampMs(action.emittedAt) ??
508
+ parseTimestampMs(action.endTime) ??
509
+ parseTimestampMs(execution.endTime) ??
510
+ parseTimestampMs(execution.startTime),
487
511
  );
488
512
  };
489
513
 
@@ -594,6 +618,7 @@ const parseExecutionActionMessageEntry = (
594
618
  raw: {
595
619
  ...action,
596
620
  executionFilePath: execution.filePath,
621
+ executionStartTime: execution.raw.startTime ?? null,
597
622
  },
598
623
  role: 'assistant',
599
624
  timestamp: getActionTimestamp(action, execution.raw),
@@ -633,6 +658,7 @@ const parseExecutionActionToolEntry = (
633
658
  raw: {
634
659
  ...action,
635
660
  executionFilePath: execution.filePath,
661
+ executionStartTime: execution.raw.startTime ?? null,
636
662
  },
637
663
  role: 'tool',
638
664
  timestamp: getActionTimestamp(action, execution.raw),
@@ -678,43 +704,87 @@ const readExecutionEntries = async (
678
704
  return executions.sort(compareExecutionFiles).flatMap(parseExecutionEntries);
679
705
  };
680
706
 
681
- const getVisibleEntries = (
682
- historyEntries: KiroTranscriptEntry[],
683
- executionEntries: KiroTranscriptEntry[],
684
- ): KiroTranscriptEntry[] => {
685
- if (executionEntries.length === 0) {
686
- return historyEntries;
687
- }
688
-
689
- const executionEntriesById = new Map<string, KiroTranscriptEntry[]>();
707
+ const groupExecutionEntriesById = (executionEntries: KiroTranscriptEntry[]) => {
708
+ const entriesById = new Map<string, KiroTranscriptEntry[]>();
690
709
  for (const entry of executionEntries) {
691
710
  if (!entry.executionId) {
692
711
  continue;
693
712
  }
694
713
 
695
- const entries = executionEntriesById.get(entry.executionId) ?? [];
714
+ const entries = entriesById.get(entry.executionId) ?? [];
696
715
  entries.push(entry);
697
- executionEntriesById.set(entry.executionId, entries);
716
+ entriesById.set(entry.executionId, entries);
698
717
  }
718
+ return entriesById;
719
+ };
699
720
 
721
+ const replaceExecutionPlaceholders = (
722
+ historyEntries: KiroTranscriptEntry[],
723
+ executionEntriesById: Map<string, KiroTranscriptEntry[]>,
724
+ ) => {
700
725
  const usedExecutionIds = new Set<string>();
701
726
  const visibleEntries: KiroTranscriptEntry[] = [];
702
727
  for (const entry of historyEntries) {
703
728
  const matchingExecutionEntries = entry.executionId ? executionEntriesById.get(entry.executionId) : undefined;
704
- if (isAssistantPlaceholderEntry(entry)) {
705
- if (matchingExecutionEntries) {
706
- visibleEntries.push(...matchingExecutionEntries);
707
- usedExecutionIds.add(entry.executionId!);
708
- }
729
+ if (!isAssistantPlaceholderEntry(entry) || !matchingExecutionEntries) {
730
+ visibleEntries.push(entry);
709
731
  continue;
710
732
  }
711
733
 
712
- visibleEntries.push(entry);
734
+ visibleEntries.push(...matchingExecutionEntries);
735
+ usedExecutionIds.add(entry.executionId!);
713
736
  }
737
+ return { usedExecutionIds, visibleEntries };
738
+ };
714
739
 
715
- visibleEntries.push(
716
- ...executionEntries.filter((entry) => !entry.executionId || !usedExecutionIds.has(entry.executionId)),
717
- );
740
+ const groupUnmatchedExecutionEntries = (executionEntries: KiroTranscriptEntry[], usedExecutionIds: Set<string>) => {
741
+ const groups = new Map<string, KiroTranscriptEntry[]>();
742
+ for (const entry of executionEntries) {
743
+ if (entry.executionId && usedExecutionIds.has(entry.executionId)) {
744
+ continue;
745
+ }
746
+
747
+ const groupKey = entry.executionId ?? asString(entry.raw.executionFilePath ?? null) ?? entry.entryId;
748
+ const group = groups.get(groupKey) ?? [];
749
+ group.push(entry);
750
+ groups.set(groupKey, group);
751
+ }
752
+ return groups.values();
753
+ };
754
+
755
+ const insertExecutionGroupByStartTime = (
756
+ visibleEntries: KiroTranscriptEntry[],
757
+ executionGroup: KiroTranscriptEntry[],
758
+ ) => {
759
+ const firstEntry = executionGroup[0]!;
760
+ const executionTime = parseTimestampMs(firstEntry.raw.executionStartTime) ?? parseTimestampMs(firstEntry.timestamp);
761
+ const insertionIndex =
762
+ executionTime === null
763
+ ? -1
764
+ : visibleEntries.findIndex((entry) => {
765
+ const entryTime = parseTimestampMs(entry.timestamp);
766
+ return entryTime !== null && entryTime > executionTime;
767
+ });
768
+ if (insertionIndex < 0) {
769
+ visibleEntries.push(...executionGroup);
770
+ } else {
771
+ visibleEntries.splice(insertionIndex, 0, ...executionGroup);
772
+ }
773
+ };
774
+
775
+ const getVisibleEntries = (
776
+ historyEntries: KiroTranscriptEntry[],
777
+ executionEntries: KiroTranscriptEntry[],
778
+ ): KiroTranscriptEntry[] => {
779
+ if (executionEntries.length === 0) {
780
+ return historyEntries;
781
+ }
782
+
783
+ const executionEntriesById = groupExecutionEntriesById(executionEntries);
784
+ const { usedExecutionIds, visibleEntries } = replaceExecutionPlaceholders(historyEntries, executionEntriesById);
785
+ for (const executionGroup of groupUnmatchedExecutionEntries(executionEntries, usedExecutionIds)) {
786
+ insertExecutionGroupByStartTime(visibleEntries, executionGroup);
787
+ }
718
788
  return visibleEntries;
719
789
  };
720
790
 
@@ -787,7 +857,7 @@ const readSessionFile = async (
787
857
  const listSessionFilesForWorkspace = async (sessionsDir: string, directoryName: string): Promise<KiroSessionFile[]> => {
788
858
  const workspaceDir = path.join(sessionsDir, directoryName);
789
859
  const index = await readSessionIndex(workspaceDir);
790
- const entries = await readdir(workspaceDir, { withFileTypes: true }).catch(() => []);
860
+ const entries = await readDirectoryEntriesIfExists(workspaceDir);
791
861
 
792
862
  return entries
793
863
  .filter((entry) => entry.isFile() && entry.name.endsWith('.json') && entry.name !== 'sessions.json')
@@ -820,7 +890,7 @@ const readSessionFiles = async (files: KiroSessionFile[]): Promise<KiroSessionTr
820
890
  const transcripts = await mapWithConcurrency(files, READ_CONCURRENCY, (file) =>
821
891
  readSessionFile(file, { includeExecutions: false, sessionsDir: path.dirname(path.dirname(file.filePath)) }),
822
892
  );
823
- return transcripts.flatMap((transcript) => (transcript ? [transcript] : []));
893
+ return transcripts.flatMap((transcript) => (transcript && transcript.renderablePartCount > 0 ? [transcript] : []));
824
894
  };
825
895
 
826
896
  const compareNullableMsDesc = (left: number | null, right: number | null): number => {
@@ -934,7 +1004,16 @@ export const listKiroSessionsForGroup = async (
934
1004
 
935
1005
  const locateSessionFile = async (sessionsDir: string, sessionId: string): Promise<KiroSessionFile | null> => {
936
1006
  const files = await listSessionFiles(sessionsDir);
937
- return files.find((file) => path.basename(file.filePath, '.json') === sessionId) ?? null;
1007
+ const filenameMatch = files.find((file) => path.basename(file.filePath, '.json') === sessionId);
1008
+ if (filenameMatch) {
1009
+ return filenameMatch;
1010
+ }
1011
+
1012
+ const bodyMatches = await mapWithConcurrency(files, READ_CONCURRENCY, async (file) => {
1013
+ const raw = await readJsonObject(file.filePath);
1014
+ return asString(raw?.sessionId ?? null) === sessionId ? file : null;
1015
+ });
1016
+ return bodyMatches.find((file): file is KiroSessionFile => file !== null) ?? null;
938
1017
  };
939
1018
 
940
1019
  const removeKiroSessionIndexEntry = async (workspaceDir: string, sessionId: string): Promise<void> => {
@@ -951,7 +1030,13 @@ const removeKiroSessionIndexEntry = async (workspaceDir: string, sessionId: stri
951
1030
  return;
952
1031
  }
953
1032
 
954
- await Bun.write(indexPath, JSON.stringify(next, null, 2));
1033
+ const tempPath = `${indexPath}.${randomUUID()}.tmp`;
1034
+ try {
1035
+ await Bun.write(tempPath, JSON.stringify(next, null, 2));
1036
+ await rename(tempPath, indexPath);
1037
+ } finally {
1038
+ await rm(tempPath, { force: true });
1039
+ }
955
1040
  };
956
1041
 
957
1042
  export const readKiroSessionTranscript = async (
@@ -970,31 +1055,39 @@ export const readKiroSessionTranscript = async (
970
1055
  return readSessionFile(file, { includeExecutions: true, sessionsDir });
971
1056
  };
972
1057
 
973
- export const deleteKiroSession = async (sessionsDir: string, sessionId: string): Promise<DeleteKiroSessionResult> => {
974
- if (!(await pathExists(sessionsDir))) {
975
- return { deletedFiles: [], deletedSessionIds: [] };
976
- }
977
-
978
- const file = await locateSessionFile(sessionsDir, sessionId);
979
- if (!file) {
980
- return { deletedFiles: [], deletedSessionIds: [] };
981
- }
1058
+ export const deleteKiroSession = (sessionsDir: string, sessionId: string): Promise<DeleteKiroSessionResult> => {
1059
+ return kiroDeleteLimiter(async () => {
1060
+ if (!(await pathExists(sessionsDir))) {
1061
+ return { deletedFiles: [], deletedSessionIds: [] };
1062
+ }
982
1063
 
983
- const transcript = await readSessionFile(file, { includeExecutions: false, sessionsDir });
984
- const executionFiles = transcript
985
- ? await listExecutionFilesForSession(
986
- getKiroDataDirFromSessionsDir(sessionsDir),
987
- sessionId,
988
- transcript.session.worktree,
989
- )
990
- : [];
991
- const deletedFiles = [file.filePath, ...executionFiles.map((execution) => execution.filePath)];
1064
+ const file = await locateSessionFile(sessionsDir, sessionId);
1065
+ if (!file) {
1066
+ return { deletedFiles: [], deletedSessionIds: [] };
1067
+ }
992
1068
 
993
- await Promise.all(deletedFiles.map((filePath) => rm(filePath, { force: true })));
994
- await removeKiroSessionIndexEntry(path.dirname(file.filePath), sessionId);
1069
+ const transcript = await readSessionFile(file, { includeExecutions: false, sessionsDir });
1070
+ const executionFiles = transcript
1071
+ ? await listExecutionFilesForSession(
1072
+ getKiroDataDirFromSessionsDir(sessionsDir),
1073
+ sessionId,
1074
+ transcript.session.worktree,
1075
+ )
1076
+ : [];
1077
+ const deletedFiles = [file.filePath, ...executionFiles.map((execution) => execution.filePath)];
1078
+
1079
+ await Promise.all(deletedFiles.map((filePath) => rm(filePath, { force: true })));
1080
+ executionFilesCache.delete(
1081
+ path.join(
1082
+ getKiroDataDirFromSessionsDir(sessionsDir),
1083
+ getKiroWorkspaceHash(transcript?.session.worktree ?? ''),
1084
+ ),
1085
+ );
1086
+ await removeKiroSessionIndexEntry(path.dirname(file.filePath), sessionId);
995
1087
 
996
- return {
997
- deletedFiles,
998
- deletedSessionIds: [sessionId],
999
- };
1088
+ return {
1089
+ deletedFiles,
1090
+ deletedSessionIds: [sessionId],
1091
+ };
1092
+ });
1000
1093
  };
@@ -61,10 +61,6 @@ const renderTextPart = (part: KiroTranscriptPart, title: string, options: KiroEx
61
61
  };
62
62
 
63
63
  const renderImagePart = (part: KiroTranscriptPart, options: KiroExportOptions): string => {
64
- if (!options.includeCommentary) {
65
- return '';
66
- }
67
-
68
64
  const text = cleanExtractedText(part.text ?? 'Image attachment').trim();
69
65
  return renderSection('Attachment', text, options.outputFormat);
70
66
  };
@@ -24,6 +24,7 @@ import {
24
24
  asString,
25
25
  isWorkspacePathQuery,
26
26
  type JsonValue,
27
+ pathExists,
27
28
  workspacePathMatchesQuery,
28
29
  } from './shared';
29
30
  import { runWithSqliteRetry } from './sqlite-retry';
@@ -120,7 +121,7 @@ const openCodeDbLimiter = createConcurrencyLimiter(resolveOpenCodeDbConcurrency(
120
121
  const openCodeDesktopStateLimiter = createConcurrencyLimiter(1);
121
122
 
122
123
  const logOpenCodeDb = (event: string, details: Record<string, unknown>) => {
123
- if (process.env.SPIRACHA_OPENCODE_DB_LOGS !== '0') {
124
+ if (process.env.SPIRACHA_OPENCODE_DB_LOGS === '1') {
124
125
  console.info(`[spiracha:opencode-db] ${event}`, details);
125
126
  }
126
127
  };
@@ -185,12 +186,6 @@ const getDefaultOpenCodeDesktopStateDir = (
185
186
  : null;
186
187
  };
187
188
 
188
- const pathExists = async (target: string): Promise<boolean> => {
189
- return await stat(target)
190
- .then(() => true)
191
- .catch(() => false);
192
- };
193
-
194
189
  export const getOpenCodeReadDbUri = (dbPath: string, mode: 'ro' | 'rw' = 'rw'): string => {
195
190
  const url = pathToFileURL(dbPath);
196
191
  url.searchParams.set('mode', mode);
@@ -1014,12 +1009,13 @@ const parseTextOpenCodePart = (base: BaseOpenCodePart): OpenCodeTranscriptPart =
1014
1009
 
1015
1010
  const parseToolOpenCodePart = (base: BaseOpenCodePart): OpenCodeTranscriptPart => {
1016
1011
  const state = asObject(base.raw.state ?? null);
1012
+ const metadata = asObject(state?.metadata ?? null);
1017
1013
  return {
1018
1014
  ...base,
1019
1015
  argumentsText: formatJsonLike(state?.input),
1020
1016
  callId: asString(base.raw.callID ?? null) ?? asString(base.raw.callId ?? null),
1021
1017
  endTimeMs: parseToolTimeMs(base.raw, 'end'),
1022
- outputText: formatJsonLike(state?.output),
1018
+ outputText: formatJsonLike(state?.output ?? metadata?.output ?? state?.error),
1023
1019
  startTimeMs: parseToolTimeMs(base.raw, 'start'),
1024
1020
  status: asString(state?.status ?? null),
1025
1021
  title: asString(state?.title ?? null),
@@ -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 => {
@@ -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
  };