spiracha 2.0.0 → 2.1.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/README.md +6 -1
- package/apps/ui/README.md +20 -2
- package/apps/ui/package.json +2 -2
- package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +1 -0
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +7 -5
- package/apps/ui/src/components/export-dialog.tsx +2 -12
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/threads-table.tsx +9 -46
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +152 -7
- package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +175 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +19 -3
- package/apps/ui/src/lib/codex-server.ts +135 -53
- package/apps/ui/src/lib/cursor-server.ts +48 -5
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +169 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +85 -7
- package/apps/ui/src/lib/opencode-server.ts +85 -7
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
- package/apps/ui/src/routeTree.gen.ts +107 -0
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
- package/apps/ui/src/routes/opencode.index.tsx +16 -3
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
- package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
- package/package.json +19 -18
- package/src/client.ts +134 -1
- package/src/lib/antigravity-db.ts +208 -32
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/claude-code-db.ts +498 -32
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-browser-export.ts +23 -27
- package/src/lib/codex-thread-cache.ts +41 -13
- package/src/lib/codex-thread-parser.ts +86 -35
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +39 -19
- package/src/lib/concurrency.ts +41 -0
- package/src/lib/conversation-api.ts +399 -19
- package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
- package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
- package/src/lib/conversation-data/codex-adapter.ts +28 -4
- package/src/lib/conversation-data/cursor-adapter.ts +35 -1
- package/src/lib/conversation-data/grok-adapter.ts +210 -0
- package/src/lib/conversation-data/index.ts +76 -1
- package/src/lib/conversation-data/kiro-adapter.ts +41 -7
- package/src/lib/conversation-data/opencode-adapter.ts +24 -2
- package/src/lib/conversation-data/qoder-adapter.ts +44 -19
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +57 -0
- package/src/lib/cursor-db.ts +34 -5
- package/src/lib/grok-db.ts +1026 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +52 -0
- package/src/lib/grok-transcript.ts +154 -0
- package/src/lib/kiro-db.ts +52 -1
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +598 -55
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/shared.ts +12 -0
- package/src/lib/transcript-load-limiter.ts +82 -0
- package/src/lib/ui-cache.ts +43 -17
- package/src/lib/ui-export-archive.ts +60 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { readdir, stat, unlink } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
type ClaudeCodeSessionSummary,
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
getDefaultClaudeCodeDataDir,
|
|
10
10
|
resolveClaudeCodeProjectsDir,
|
|
11
11
|
} from './claude-code-exporter-types';
|
|
12
|
+
import {
|
|
13
|
+
getClaudeCodeAssistantMessagePhase,
|
|
14
|
+
isClaudeCodeRawFlagEnabled,
|
|
15
|
+
isClaudeCodeSyntheticTranscriptEntry,
|
|
16
|
+
} from './claude-code-transcript-phase';
|
|
12
17
|
import { mapWithConcurrency } from './concurrency';
|
|
13
18
|
import {
|
|
14
19
|
asBoolean,
|
|
@@ -38,6 +43,18 @@ type ParsedTranscriptFile = {
|
|
|
38
43
|
transcript: ClaudeCodeSessionTranscript;
|
|
39
44
|
};
|
|
40
45
|
|
|
46
|
+
type ReadTranscriptFileOptions = {
|
|
47
|
+
includeRawPayloads?: boolean;
|
|
48
|
+
maxRawPayloadFileSizeBytes?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ReadClaudeCodeSessionTranscriptOptions = ReadTranscriptFileOptions;
|
|
52
|
+
|
|
53
|
+
export type DeleteClaudeCodeSessionResult = {
|
|
54
|
+
deletedFiles: string[];
|
|
55
|
+
deletedSessionIds: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
41
58
|
type SessionStats = {
|
|
42
59
|
assistantMessageCount: number;
|
|
43
60
|
attachmentCount: number;
|
|
@@ -52,6 +69,11 @@ type SessionStats = {
|
|
|
52
69
|
userMessageCount: number;
|
|
53
70
|
};
|
|
54
71
|
|
|
72
|
+
type SessionStatsTracker = {
|
|
73
|
+
assistantMessageIds: Set<string>;
|
|
74
|
+
usageMessageIds: Set<string>;
|
|
75
|
+
};
|
|
76
|
+
|
|
55
77
|
type SessionIdentity = {
|
|
56
78
|
cwd: string | null;
|
|
57
79
|
firstUserText: string | null;
|
|
@@ -75,6 +97,18 @@ const pathExists = async (target: string): Promise<boolean> => {
|
|
|
75
97
|
.catch(() => false);
|
|
76
98
|
};
|
|
77
99
|
|
|
100
|
+
const unlinkIfPresent = async (target: string): Promise<boolean> => {
|
|
101
|
+
try {
|
|
102
|
+
await unlink(target);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
78
112
|
const cleanLabel = (value: string | null | undefined): string | null => {
|
|
79
113
|
const cleaned = value?.replace(/\s+/g, ' ').trim();
|
|
80
114
|
return cleaned ? cleaned : null;
|
|
@@ -281,19 +315,21 @@ const parseTranscriptEntry = (raw: Record<string, JsonValue>): ClaudeCodeTranscr
|
|
|
281
315
|
const type = asString(raw.type ?? null) ?? 'unknown';
|
|
282
316
|
const message = asObject(raw.message ?? null);
|
|
283
317
|
const parts = getTranscriptEntryParts(type, message, raw);
|
|
318
|
+
const role = getTranscriptEntryRole(type, message);
|
|
284
319
|
|
|
285
320
|
if (parts.length === 0) {
|
|
286
321
|
return null;
|
|
287
322
|
}
|
|
288
323
|
|
|
289
324
|
return {
|
|
325
|
+
assistantPhase: getClaudeCodeAssistantMessagePhase({ parts, raw, role }),
|
|
290
326
|
cwd: asString(raw.cwd ?? null),
|
|
291
327
|
entryId: getTranscriptEntryId(type, raw),
|
|
292
328
|
model: asString(message?.model ?? null),
|
|
293
329
|
parentEntryId: asString(raw.parentUuid ?? null),
|
|
294
330
|
parts,
|
|
295
331
|
raw,
|
|
296
|
-
role
|
|
332
|
+
role,
|
|
297
333
|
timestamp: asString(raw.timestamp ?? null),
|
|
298
334
|
type,
|
|
299
335
|
};
|
|
@@ -313,31 +349,87 @@ const createEmptyStats = (): SessionStats => ({
|
|
|
313
349
|
userMessageCount: 0,
|
|
314
350
|
});
|
|
315
351
|
|
|
316
|
-
const
|
|
352
|
+
const createStatsTracker = (): SessionStatsTracker => ({
|
|
353
|
+
assistantMessageIds: new Set(),
|
|
354
|
+
usageMessageIds: new Set(),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const getMessageIdentity = (entry: ClaudeCodeTranscriptEntry): string => {
|
|
358
|
+
const message = asObject(entry.raw.message ?? null);
|
|
359
|
+
return asString(message?.id ?? null) ?? entry.entryId;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const addUsageStats = (stats: SessionStats, tracker: SessionStatsTracker, entry: ClaudeCodeTranscriptEntry) => {
|
|
363
|
+
const message = asObject(entry.raw.message ?? null);
|
|
317
364
|
const usage = asObject(message?.usage ?? null);
|
|
318
365
|
if (!usage) {
|
|
319
366
|
return;
|
|
320
367
|
}
|
|
321
368
|
|
|
369
|
+
const messageId = getMessageIdentity(entry);
|
|
370
|
+
if (tracker.usageMessageIds.has(messageId)) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
tracker.usageMessageIds.add(messageId);
|
|
374
|
+
|
|
322
375
|
stats.inputTokens += asNumber(usage.input_tokens ?? null) ?? 0;
|
|
323
376
|
stats.outputTokens += asNumber(usage.output_tokens ?? null) ?? 0;
|
|
324
377
|
stats.cacheCreationInputTokens += asNumber(usage.cache_creation_input_tokens ?? null) ?? 0;
|
|
325
378
|
stats.cacheReadInputTokens += asNumber(usage.cache_read_input_tokens ?? null) ?? 0;
|
|
326
379
|
};
|
|
327
380
|
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
381
|
+
const hasTextPart = (entry: ClaudeCodeTranscriptEntry): boolean => {
|
|
382
|
+
return entry.parts.some((part) => part.type === 'text' && part.text?.trim());
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const extractUserPromptText = (text: string): string | null => {
|
|
386
|
+
const cleaned = cleanExtractedText(text).trim();
|
|
387
|
+
if (!cleaned.startsWith('<!-- attach -->')) {
|
|
388
|
+
return cleaned || null;
|
|
331
389
|
}
|
|
332
390
|
|
|
333
|
-
|
|
391
|
+
const lines = cleaned.split('\n').slice(1);
|
|
392
|
+
let sawQuotedAttachment = false;
|
|
393
|
+
for (const [index, line] of lines.entries()) {
|
|
394
|
+
const trimmed = line.trim();
|
|
395
|
+
if (trimmed.startsWith('>')) {
|
|
396
|
+
sawQuotedAttachment = true;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (!trimmed) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (sawQuotedAttachment) {
|
|
403
|
+
return lines.slice(index).join('\n').trim() || null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return null;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const updateMessageStats = (stats: SessionStats, tracker: SessionStatsTracker, entry: ClaudeCodeTranscriptEntry) => {
|
|
411
|
+
if (entry.role === 'user' && hasTextPart(entry) && !isClaudeCodeSyntheticTranscriptEntry(entry)) {
|
|
412
|
+
stats.messageCount += 1;
|
|
334
413
|
stats.userMessageCount += 1;
|
|
414
|
+
return;
|
|
335
415
|
}
|
|
336
416
|
|
|
337
|
-
if (entry.role
|
|
338
|
-
|
|
417
|
+
if (entry.role !== 'assistant' || !hasTextPart(entry)) {
|
|
418
|
+
return;
|
|
339
419
|
}
|
|
340
420
|
|
|
421
|
+
const messageId = getMessageIdentity(entry);
|
|
422
|
+
if (tracker.assistantMessageIds.has(messageId)) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
tracker.assistantMessageIds.add(messageId);
|
|
426
|
+
stats.messageCount += 1;
|
|
427
|
+
stats.assistantMessageCount += 1;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const updateStatsFromEntry = (stats: SessionStats, tracker: SessionStatsTracker, entry: ClaudeCodeTranscriptEntry) => {
|
|
431
|
+
updateMessageStats(stats, tracker, entry);
|
|
432
|
+
|
|
341
433
|
if (entry.type === 'attachment') {
|
|
342
434
|
stats.attachmentCount += 1;
|
|
343
435
|
}
|
|
@@ -345,7 +437,7 @@ const updateStatsFromEntry = (stats: SessionStats, entry: ClaudeCodeTranscriptEn
|
|
|
345
437
|
stats.toolCallCount += entry.parts.filter((part) => part.type === 'tool_use').length;
|
|
346
438
|
stats.toolResultCount += entry.parts.filter((part) => part.type === 'tool_result').length;
|
|
347
439
|
stats.renderablePartCount += entry.parts.filter(isRenderablePart).length;
|
|
348
|
-
addUsageStats(stats,
|
|
440
|
+
addUsageStats(stats, tracker, entry);
|
|
349
441
|
};
|
|
350
442
|
|
|
351
443
|
const updateTimeline = (
|
|
@@ -382,9 +474,9 @@ const updateIdentityFromRaw = (identity: SessionIdentity, raw: Record<string, Js
|
|
|
382
474
|
};
|
|
383
475
|
|
|
384
476
|
const updateIdentityFromEntry = (identity: SessionIdentity, entry: ClaudeCodeTranscriptEntry) => {
|
|
385
|
-
if (entry.role === 'user' && !identity.firstUserText) {
|
|
477
|
+
if (entry.role === 'user' && !identity.firstUserText && !isClaudeCodeSyntheticTranscriptEntry(entry)) {
|
|
386
478
|
const textPart = entry.parts.find((part) => part.type === 'text' && part.text?.trim());
|
|
387
|
-
identity.firstUserText =
|
|
479
|
+
identity.firstUserText = extractUserPromptText(textPart?.text ?? '');
|
|
388
480
|
}
|
|
389
481
|
};
|
|
390
482
|
|
|
@@ -450,10 +542,58 @@ const isRenderablePart = (part: ClaudeCodeTranscriptPart): boolean => {
|
|
|
450
542
|
return false;
|
|
451
543
|
};
|
|
452
544
|
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
.
|
|
545
|
+
const stripEntryRawPayloads = (entry: ClaudeCodeTranscriptEntry): ClaudeCodeTranscriptEntry => {
|
|
546
|
+
return {
|
|
547
|
+
...entry,
|
|
548
|
+
parts: entry.parts.map((part) => ({ ...part, raw: {} })),
|
|
549
|
+
raw: {},
|
|
550
|
+
};
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const markIncompleteAssistantParentAsCommentary = (
|
|
554
|
+
entries: ClaudeCodeTranscriptEntry[],
|
|
555
|
+
entry: ClaudeCodeTranscriptEntry,
|
|
556
|
+
): void => {
|
|
557
|
+
const interruptedByUser =
|
|
558
|
+
entry.role === 'user' &&
|
|
559
|
+
entry.parts.some(
|
|
560
|
+
(part) => part.type === 'text' && part.text?.trim().startsWith('[Request interrupted by user]'),
|
|
561
|
+
);
|
|
562
|
+
if ((!interruptedByUser && !isClaudeCodeRawFlagEnabled(entry.raw.isApiErrorMessage)) || !entry.parentEntryId) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
567
|
+
const candidate = entries[index];
|
|
568
|
+
if (candidate?.entryId === entry.parentEntryId && candidate.role === 'assistant') {
|
|
569
|
+
candidate.assistantPhase = 'commentary';
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const markLatestAssistantAsCommentary = (entries: ClaudeCodeTranscriptEntry[]): void => {
|
|
576
|
+
const latestEntry = entries.at(-1);
|
|
577
|
+
if (latestEntry?.role === 'assistant') {
|
|
578
|
+
latestEntry.assistantPhase = 'commentary';
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const isSubagentTaskNotification = (raw: Record<string, JsonValue>): boolean => {
|
|
583
|
+
return (
|
|
584
|
+
raw.type === 'queue-operation' &&
|
|
585
|
+
asString(raw.content ?? null)
|
|
586
|
+
?.trimStart()
|
|
587
|
+
.startsWith('<task-notification>') === true
|
|
588
|
+
);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const buildTranscriptFromRawEvents = (
|
|
592
|
+
file: TranscriptFile,
|
|
593
|
+
sourceRawEvents: Record<string, JsonValue>[],
|
|
594
|
+
fallbackMtimeMs: number | null,
|
|
595
|
+
includeRawPayloads: boolean,
|
|
596
|
+
): ClaudeCodeSessionTranscript => {
|
|
457
597
|
const identity: SessionIdentity = {
|
|
458
598
|
cwd: null,
|
|
459
599
|
firstUserText: null,
|
|
@@ -464,33 +604,66 @@ const readTranscriptFile = async (file: TranscriptFile): Promise<ParsedTranscrip
|
|
|
464
604
|
version: null,
|
|
465
605
|
};
|
|
466
606
|
const stats = createEmptyStats();
|
|
607
|
+
const statsTracker = createStatsTracker();
|
|
467
608
|
const timeline = { firstMs: null as number | null, lastMs: null as number | null };
|
|
468
609
|
const entries: ClaudeCodeTranscriptEntry[] = [];
|
|
469
|
-
const rawEvents: Record<string, JsonValue>[] = [];
|
|
470
610
|
|
|
471
|
-
for
|
|
472
|
-
rawEvents.push(raw);
|
|
611
|
+
for (const raw of sourceRawEvents) {
|
|
473
612
|
updateTimeline(timeline, raw);
|
|
474
613
|
updateIdentityFromRaw(identity, raw);
|
|
475
614
|
|
|
615
|
+
if (isSubagentTaskNotification(raw)) {
|
|
616
|
+
markLatestAssistantAsCommentary(entries);
|
|
617
|
+
}
|
|
618
|
+
|
|
476
619
|
const entry = parseTranscriptEntry(raw);
|
|
477
620
|
if (!entry) {
|
|
478
621
|
continue;
|
|
479
622
|
}
|
|
480
623
|
|
|
481
|
-
entries
|
|
482
|
-
|
|
624
|
+
markIncompleteAssistantParentAsCommentary(entries, entry);
|
|
625
|
+
if (isClaudeCodeSyntheticTranscriptEntry(entry)) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
entries.push(includeRawPayloads ? entry : stripEntryRawPayloads(entry));
|
|
630
|
+
updateStatsFromEntry(stats, statsTracker, entry);
|
|
483
631
|
updateIdentityFromEntry(identity, entry);
|
|
484
632
|
}
|
|
485
633
|
|
|
486
634
|
const session = toSessionSummary(file, identity, stats, toTimeline(timeline, fallbackMtimeMs));
|
|
487
635
|
return {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
636
|
+
entries,
|
|
637
|
+
rawEvents: includeRawPayloads ? sourceRawEvents : [],
|
|
638
|
+
rawPayloadsOmitted: includeRawPayloads ? undefined : true,
|
|
639
|
+
renderablePartCount: stats.renderablePartCount,
|
|
640
|
+
session,
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const readTranscriptFile = async (
|
|
645
|
+
file: TranscriptFile,
|
|
646
|
+
options: ReadTranscriptFileOptions = {},
|
|
647
|
+
): Promise<ParsedTranscriptFile | null> => {
|
|
648
|
+
const includeRawPayloads = options.includeRawPayloads ?? true;
|
|
649
|
+
const fallbackMtimeMs = await stat(file.filePath)
|
|
650
|
+
.then((stats) => stats.mtimeMs)
|
|
651
|
+
.catch(() => null);
|
|
652
|
+
const rawEvents: Record<string, JsonValue>[] = [];
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
for await (const raw of readJsonlObjects(file.filePath)) {
|
|
656
|
+
rawEvents.push(raw);
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
transcript: buildTranscriptFromRawEvents(file, rawEvents, fallbackMtimeMs, includeRawPayloads),
|
|
494
667
|
};
|
|
495
668
|
};
|
|
496
669
|
|
|
@@ -521,10 +694,235 @@ const listTranscriptFiles = async (projectsDir: string): Promise<TranscriptFile[
|
|
|
521
694
|
};
|
|
522
695
|
|
|
523
696
|
const readTranscriptFiles = async (files: TranscriptFile[]): Promise<ClaudeCodeSessionTranscript[]> => {
|
|
524
|
-
const parsed = await mapWithConcurrency(files, READ_CONCURRENCY, readTranscriptFile);
|
|
697
|
+
const parsed = await mapWithConcurrency(files, READ_CONCURRENCY, (file) => readTranscriptFile(file));
|
|
525
698
|
return parsed.flatMap((item) => (item ? [item.transcript] : []));
|
|
526
699
|
};
|
|
527
700
|
|
|
701
|
+
const getCompactionSummaryId = (raw: Record<string, JsonValue>): string | null => {
|
|
702
|
+
return raw.isCompactSummary === true ? asString(raw.uuid ?? null) : null;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const getCompactionSummaryIds = (transcript: ClaudeCodeSessionTranscript): string[] => {
|
|
706
|
+
return [...new Set(transcript.rawEvents.map(getCompactionSummaryId).filter((id): id is string => id !== null))];
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
type TranscriptLineageIndex = {
|
|
710
|
+
anchorsByIndex: string[][];
|
|
711
|
+
indexesByAnchor: Map<string, number[]>;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const indexTranscriptLineageAnchors = (transcripts: ClaudeCodeSessionTranscript[]): TranscriptLineageIndex => {
|
|
715
|
+
const indexesByAnchor = new Map<string, number[]>();
|
|
716
|
+
const anchorsByIndex = transcripts.map((transcript, index) => {
|
|
717
|
+
const anchors = getCompactionSummaryIds(transcript).map(
|
|
718
|
+
(anchorId) => `${transcript.session.workspaceKey}\0${anchorId}`,
|
|
719
|
+
);
|
|
720
|
+
for (const anchor of anchors) {
|
|
721
|
+
const indexes = indexesByAnchor.get(anchor) ?? [];
|
|
722
|
+
indexes.push(index);
|
|
723
|
+
indexesByAnchor.set(anchor, indexes);
|
|
724
|
+
}
|
|
725
|
+
return anchors;
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
return { anchorsByIndex, indexesByAnchor };
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const collectTranscriptLineage = (
|
|
732
|
+
startIndex: number,
|
|
733
|
+
transcripts: ClaudeCodeSessionTranscript[],
|
|
734
|
+
index: TranscriptLineageIndex,
|
|
735
|
+
visited: Set<number>,
|
|
736
|
+
): ClaudeCodeSessionTranscript[] => {
|
|
737
|
+
const lineage: ClaudeCodeSessionTranscript[] = [];
|
|
738
|
+
const pending = [startIndex];
|
|
739
|
+
visited.add(startIndex);
|
|
740
|
+
|
|
741
|
+
while (pending.length > 0) {
|
|
742
|
+
const currentIndex = pending.pop() as number;
|
|
743
|
+
const transcript = transcripts[currentIndex];
|
|
744
|
+
if (!transcript) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
lineage.push(transcript);
|
|
748
|
+
|
|
749
|
+
const neighborIndexes = (index.anchorsByIndex[currentIndex] ?? []).flatMap(
|
|
750
|
+
(anchor) => index.indexesByAnchor.get(anchor) ?? [],
|
|
751
|
+
);
|
|
752
|
+
for (const neighborIndex of neighborIndexes) {
|
|
753
|
+
if (!visited.has(neighborIndex)) {
|
|
754
|
+
visited.add(neighborIndex);
|
|
755
|
+
pending.push(neighborIndex);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return lineage;
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const getTranscriptLineages = (transcripts: ClaudeCodeSessionTranscript[]): ClaudeCodeSessionTranscript[][] => {
|
|
764
|
+
const index = indexTranscriptLineageAnchors(transcripts);
|
|
765
|
+
const visited = new Set<number>();
|
|
766
|
+
const lineages: ClaudeCodeSessionTranscript[][] = [];
|
|
767
|
+
|
|
768
|
+
for (const startIndex of transcripts.keys()) {
|
|
769
|
+
if (visited.has(startIndex)) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
lineages.push(collectTranscriptLineage(startIndex, transcripts, index, visited));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return lineages;
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const countContentEntriesBefore = (
|
|
779
|
+
transcript: ClaudeCodeSessionTranscript,
|
|
780
|
+
rawEventIndex: number,
|
|
781
|
+
prefixCounts: WeakMap<ClaudeCodeSessionTranscript, number[]>,
|
|
782
|
+
): number => {
|
|
783
|
+
let counts = prefixCounts.get(transcript);
|
|
784
|
+
if (!counts) {
|
|
785
|
+
counts = [0];
|
|
786
|
+
for (const raw of transcript.rawEvents) {
|
|
787
|
+
const entry = parseTranscriptEntry(raw);
|
|
788
|
+
counts.push((counts.at(-1) ?? 0) + (entry && !isClaudeCodeSyntheticTranscriptEntry(entry) ? 1 : 0));
|
|
789
|
+
}
|
|
790
|
+
prefixCounts.set(transcript, counts);
|
|
791
|
+
}
|
|
792
|
+
return counts[Math.max(0, Math.min(rawEventIndex, counts.length - 1))] ?? 0;
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const deduplicateRawEvents = (rawEvents: Record<string, JsonValue>[]): Record<string, JsonValue>[] => {
|
|
796
|
+
const seenIds = new Set<string>();
|
|
797
|
+
return rawEvents.filter((raw) => {
|
|
798
|
+
const eventId = asString(raw.uuid ?? null);
|
|
799
|
+
if (!eventId) {
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
if (seenIds.has(eventId)) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
seenIds.add(eventId);
|
|
806
|
+
return true;
|
|
807
|
+
});
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const MAX_CLAUDE_LINEAGE_DEPTH = 64;
|
|
811
|
+
|
|
812
|
+
const buildLogicalRawEvents = (
|
|
813
|
+
lineage: ClaudeCodeSessionTranscript[],
|
|
814
|
+
current: ClaudeCodeSessionTranscript,
|
|
815
|
+
context: {
|
|
816
|
+
memo: WeakMap<ClaudeCodeSessionTranscript, Record<string, JsonValue>[]>;
|
|
817
|
+
prefixCounts: WeakMap<ClaudeCodeSessionTranscript, number[]>;
|
|
818
|
+
} = { memo: new WeakMap(), prefixCounts: new WeakMap() },
|
|
819
|
+
visiting = new Set<string>(),
|
|
820
|
+
depth = 0,
|
|
821
|
+
): Record<string, JsonValue>[] => {
|
|
822
|
+
const cached = context.memo.get(current);
|
|
823
|
+
if (cached) {
|
|
824
|
+
return cached;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const finish = (events: Record<string, JsonValue>[]) => {
|
|
828
|
+
const deduplicated = deduplicateRawEvents(events);
|
|
829
|
+
context.memo.set(current, deduplicated);
|
|
830
|
+
return deduplicated;
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
if (visiting.has(current.session.sessionId) || depth >= MAX_CLAUDE_LINEAGE_DEPTH) {
|
|
834
|
+
return deduplicateRawEvents(current.rawEvents);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const firstAnchorIndex = current.rawEvents.findIndex((raw) => getCompactionSummaryId(raw) !== null);
|
|
838
|
+
const firstAnchor = firstAnchorIndex < 0 ? null : getCompactionSummaryId(current.rawEvents[firstAnchorIndex] ?? {});
|
|
839
|
+
if (!firstAnchor) {
|
|
840
|
+
return finish(current.rawEvents);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const currentPrefixSize = countContentEntriesBefore(current, firstAnchorIndex, context.prefixCounts);
|
|
844
|
+
const ancestor = lineage
|
|
845
|
+
.filter((candidate) => candidate.session.sessionId !== current.session.sessionId)
|
|
846
|
+
.map((candidate) => {
|
|
847
|
+
const anchorIndex = candidate.rawEvents.findIndex((raw) => getCompactionSummaryId(raw) === firstAnchor);
|
|
848
|
+
return {
|
|
849
|
+
anchorIndex,
|
|
850
|
+
candidate,
|
|
851
|
+
prefixSize:
|
|
852
|
+
anchorIndex < 0 ? -1 : countContentEntriesBefore(candidate, anchorIndex, context.prefixCounts),
|
|
853
|
+
};
|
|
854
|
+
})
|
|
855
|
+
.filter((candidate) => candidate.prefixSize > currentPrefixSize)
|
|
856
|
+
.sort(
|
|
857
|
+
(left, right) =>
|
|
858
|
+
right.prefixSize - left.prefixSize ||
|
|
859
|
+
(left.candidate.session.createdAtMs ?? 0) - (right.candidate.session.createdAtMs ?? 0),
|
|
860
|
+
)[0];
|
|
861
|
+
if (!ancestor) {
|
|
862
|
+
return finish(current.rawEvents);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const nextVisiting = new Set(visiting);
|
|
866
|
+
nextVisiting.add(current.session.sessionId);
|
|
867
|
+
const ancestorEvents = buildLogicalRawEvents(lineage, ancestor.candidate, context, nextVisiting, depth + 1);
|
|
868
|
+
const ancestorAnchorIndex = ancestorEvents.findIndex((raw) => getCompactionSummaryId(raw) === firstAnchor);
|
|
869
|
+
if (ancestorAnchorIndex < 0) {
|
|
870
|
+
return finish(current.rawEvents);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return finish([...ancestorEvents.slice(0, ancestorAnchorIndex), ...current.rawEvents.slice(firstAnchorIndex)]);
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const compareTranscriptsByActivity = (
|
|
877
|
+
left: ClaudeCodeSessionTranscript,
|
|
878
|
+
right: ClaudeCodeSessionTranscript,
|
|
879
|
+
): number => {
|
|
880
|
+
return (
|
|
881
|
+
compareNullableMsDesc(left.session.lastActiveAtMs, right.session.lastActiveAtMs) ||
|
|
882
|
+
left.session.sessionId.localeCompare(right.session.sessionId)
|
|
883
|
+
);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const coalesceTranscriptLineage = (lineage: ClaudeCodeSessionTranscript[]): ClaudeCodeSessionTranscript | null => {
|
|
887
|
+
const canonical = [...lineage].sort(compareTranscriptsByActivity)[0];
|
|
888
|
+
if (!canonical) {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const directoryName = getDirectoryNameFromWorkspaceKey(canonical.session.workspaceKey);
|
|
893
|
+
if (!directoryName) {
|
|
894
|
+
return canonical;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const transcript = buildTranscriptFromRawEvents(
|
|
898
|
+
{ directoryName, filePath: canonical.session.filePath },
|
|
899
|
+
buildLogicalRawEvents(lineage, canonical),
|
|
900
|
+
canonical.session.lastActiveAtMs,
|
|
901
|
+
true,
|
|
902
|
+
);
|
|
903
|
+
transcript.session.filePath = canonical.session.filePath;
|
|
904
|
+
transcript.session.sessionId = canonical.session.sessionId;
|
|
905
|
+
return transcript;
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const coalesceTranscriptLineages = (transcripts: ClaudeCodeSessionTranscript[]): ClaudeCodeSessionTranscript[] => {
|
|
909
|
+
return getTranscriptLineages(transcripts).flatMap((lineage) => {
|
|
910
|
+
const transcript = coalesceTranscriptLineage(lineage);
|
|
911
|
+
return transcript ? [transcript] : [];
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const omitTranscriptRawPayloads = (transcript: ClaudeCodeSessionTranscript): ClaudeCodeSessionTranscript => ({
|
|
916
|
+
...transcript,
|
|
917
|
+
entries: transcript.entries.map(stripEntryRawPayloads),
|
|
918
|
+
rawEvents: [],
|
|
919
|
+
rawPayloadsOmitted: true,
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const hasSessionContent = (transcript: ClaudeCodeSessionTranscript): boolean => {
|
|
923
|
+
return transcript.session.userMessageCount > 0 || transcript.session.assistantMessageCount > 0;
|
|
924
|
+
};
|
|
925
|
+
|
|
528
926
|
const compareNullableMsDesc = (left: number | null, right: number | null): number => {
|
|
529
927
|
return (right ?? 0) - (left ?? 0);
|
|
530
928
|
};
|
|
@@ -560,7 +958,7 @@ export const listClaudeCodeWorkspaceGroups = async (
|
|
|
560
958
|
projectsDir = resolveClaudeCodeProjectsDir(),
|
|
561
959
|
): Promise<ClaudeCodeWorkspaceGroup[]> => {
|
|
562
960
|
const files = await listTranscriptFiles(projectsDir);
|
|
563
|
-
const transcripts = await readTranscriptFiles(files);
|
|
961
|
+
const transcripts = coalesceTranscriptLineages(await readTranscriptFiles(files));
|
|
564
962
|
const sessionsByDirectory = new Map<string, ClaudeCodeSessionSummary[]>();
|
|
565
963
|
|
|
566
964
|
for (const transcript of transcripts) {
|
|
@@ -569,6 +967,10 @@ export const listClaudeCodeWorkspaceGroups = async (
|
|
|
569
967
|
continue;
|
|
570
968
|
}
|
|
571
969
|
|
|
970
|
+
if (!hasSessionContent(transcript)) {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
|
|
572
974
|
const sessions = sessionsByDirectory.get(directoryName) ?? [];
|
|
573
975
|
sessions.push(transcript.session);
|
|
574
976
|
sessionsByDirectory.set(directoryName, sessions);
|
|
@@ -629,8 +1031,8 @@ export const listClaudeCodeSessionsForGroup = async (
|
|
|
629
1031
|
}
|
|
630
1032
|
|
|
631
1033
|
const files = await listTranscriptFilesForProject(projectsDir, directoryName);
|
|
632
|
-
const transcripts = await readTranscriptFiles(files);
|
|
633
|
-
return sortSessions(transcripts.map((transcript) => transcript.session));
|
|
1034
|
+
const transcripts = coalesceTranscriptLineages(await readTranscriptFiles(files));
|
|
1035
|
+
return sortSessions(transcripts.filter(hasSessionContent).map((transcript) => transcript.session));
|
|
634
1036
|
};
|
|
635
1037
|
|
|
636
1038
|
const locateSessionFile = async (projectsDir: string, sessionId: string): Promise<TranscriptFile | null> => {
|
|
@@ -641,6 +1043,7 @@ const locateSessionFile = async (projectsDir: string, sessionId: string): Promis
|
|
|
641
1043
|
export const readClaudeCodeSessionTranscript = async (
|
|
642
1044
|
projectsDir: string,
|
|
643
1045
|
sessionId: string,
|
|
1046
|
+
options: ReadClaudeCodeSessionTranscriptOptions = {},
|
|
644
1047
|
): Promise<ClaudeCodeSessionTranscript | null> => {
|
|
645
1048
|
if (!(await pathExists(projectsDir))) {
|
|
646
1049
|
return null;
|
|
@@ -651,5 +1054,68 @@ export const readClaudeCodeSessionTranscript = async (
|
|
|
651
1054
|
return null;
|
|
652
1055
|
}
|
|
653
1056
|
|
|
654
|
-
|
|
1057
|
+
const files = await listTranscriptFilesForProject(projectsDir, file.directoryName);
|
|
1058
|
+
const transcripts = await readTranscriptFiles(files);
|
|
1059
|
+
const lineage = getTranscriptLineages(transcripts).find((candidate) =>
|
|
1060
|
+
candidate.some((transcript) => transcript.session.sessionId === sessionId),
|
|
1061
|
+
);
|
|
1062
|
+
if (!lineage) {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const transcript = coalesceTranscriptLineage(lineage);
|
|
1067
|
+
if (!transcript) {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
const lineageFileSizeBytes =
|
|
1071
|
+
options.maxRawPayloadFileSizeBytes === undefined
|
|
1072
|
+
? 0
|
|
1073
|
+
: (
|
|
1074
|
+
await Promise.all(
|
|
1075
|
+
lineage.map((candidate) =>
|
|
1076
|
+
stat(candidate.session.filePath)
|
|
1077
|
+
.then((metadata) => metadata.size)
|
|
1078
|
+
.catch(() => options.maxRawPayloadFileSizeBytes! + 1),
|
|
1079
|
+
),
|
|
1080
|
+
)
|
|
1081
|
+
).reduce((total, size) => total + size, 0);
|
|
1082
|
+
const omitRawPayloads =
|
|
1083
|
+
options.includeRawPayloads === false ||
|
|
1084
|
+
(options.maxRawPayloadFileSizeBytes !== undefined && lineageFileSizeBytes > options.maxRawPayloadFileSizeBytes);
|
|
1085
|
+
return omitRawPayloads ? omitTranscriptRawPayloads(transcript) : transcript;
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
export const deleteClaudeCodeSession = async (
|
|
1089
|
+
projectsDir: string,
|
|
1090
|
+
sessionId: string,
|
|
1091
|
+
): Promise<DeleteClaudeCodeSessionResult> => {
|
|
1092
|
+
if (!(await pathExists(projectsDir))) {
|
|
1093
|
+
return { deletedFiles: [], deletedSessionIds: [] };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const file = await locateSessionFile(projectsDir, sessionId);
|
|
1097
|
+
if (!file) {
|
|
1098
|
+
return { deletedFiles: [], deletedSessionIds: [] };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const files = await listTranscriptFilesForProject(projectsDir, file.directoryName);
|
|
1102
|
+
const transcripts = await readTranscriptFiles(files);
|
|
1103
|
+
const lineage = getTranscriptLineages(transcripts).find((candidate) =>
|
|
1104
|
+
candidate.some((transcript) => transcript.session.sessionId === sessionId),
|
|
1105
|
+
);
|
|
1106
|
+
const targets = (lineage ?? [])
|
|
1107
|
+
.map((transcript) => ({
|
|
1108
|
+
filePath: transcript.session.filePath,
|
|
1109
|
+
sessionId: transcript.session.sessionId,
|
|
1110
|
+
}))
|
|
1111
|
+
.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
1112
|
+
if (targets.length === 0) {
|
|
1113
|
+
return { deletedFiles: [], deletedSessionIds: [] };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const removed = await Promise.all(targets.map((target) => unlinkIfPresent(target.filePath)));
|
|
1117
|
+
return {
|
|
1118
|
+
deletedFiles: targets.flatMap((target, index) => (removed[index] ? [target.filePath] : [])),
|
|
1119
|
+
deletedSessionIds: targets.flatMap((target, index) => (removed[index] ? [target.sessionId] : [])),
|
|
1120
|
+
};
|
|
655
1121
|
};
|