spiracha 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -12
- package/README.md +37 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +17 -10
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
- package/apps/ui/src/components/app-shell.tsx +45 -3
- package/apps/ui/src/components/data-table.tsx +17 -1
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +77 -62
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +4 -1
- package/apps/ui/src/lib/antigravity-server.ts +70 -17
- package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
- package/apps/ui/src/lib/claude-code-server.ts +14 -4
- package/apps/ui/src/lib/codex-queries.ts +3 -3
- package/apps/ui/src/lib/codex-server.ts +3 -3
- package/apps/ui/src/lib/cursor-server.ts +41 -69
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/grok-server.ts +2 -3
- package/apps/ui/src/lib/kiro-server.ts +14 -2
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +13 -2
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +1 -1
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
- package/apps/ui/src/routeTree.gen.ts +282 -282
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
- package/apps/ui/src/routes/grok.index.tsx +2 -2
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
- package/apps/ui/src/routes/opencode.index.tsx +8 -17
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +26 -17
- package/src/client.ts +61 -16
- package/src/lib/antigravity-db.ts +236 -82
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +45 -19
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +2 -7
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +88 -33
- package/src/lib/codex-thread-parser.ts +78 -35
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-renderer.ts +49 -61
- package/src/lib/concurrency.ts +15 -3
- package/src/lib/conversation-api.ts +34 -31
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
- package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
- package/src/lib/conversation-data/codex-adapter.ts +91 -80
- package/src/lib/conversation-data/cursor-adapter.ts +48 -34
- package/src/lib/conversation-data/grok-adapter.ts +19 -6
- package/src/lib/conversation-data/index.ts +29 -10
- package/src/lib/conversation-data/kiro-adapter.ts +18 -5
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +43 -15
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +23 -18
- package/src/lib/conversation-zip-export.ts +20 -2
- package/src/lib/cursor-db.ts +34 -23
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +76 -34
- package/src/lib/grok-transcript-phase.ts +11 -2
- package/src/lib/grok-transcript.ts +15 -2
- package/src/lib/kiro-db.ts +161 -68
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/opencode-db.ts +4 -8
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +51 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +8 -3
- package/src/lib/ui-cache.ts +152 -17
- package/src/lib/ui-export-archive.ts +14 -55
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- package/apps/ui/package.json +0 -65
|
@@ -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 = (
|
|
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;
|
package/src/lib/kiro-db.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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.
|
|
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) ??
|
|
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
|
|
682
|
-
|
|
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 =
|
|
714
|
+
const entries = entriesById.get(entry.executionId) ?? [];
|
|
696
715
|
entries.push(entry);
|
|
697
|
-
|
|
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
|
-
|
|
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(
|
|
734
|
+
visibleEntries.push(...matchingExecutionEntries);
|
|
735
|
+
usedExecutionIds.add(entry.executionId!);
|
|
713
736
|
}
|
|
737
|
+
return { usedExecutionIds, visibleEntries };
|
|
738
|
+
};
|
|
714
739
|
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
994
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
};
|
package/src/lib/opencode-db.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
233
|
+
if (isQoderAcpResponse(message, INITIALIZE_REQUEST_ID)) {
|
|
225
234
|
sendLoadRequest();
|
|
226
235
|
return;
|
|
227
236
|
}
|
package/src/lib/qoder-db.ts
CHANGED
|
@@ -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) ?
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
1498
|
-
.
|
|
1499
|
-
|
|
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
|
};
|